From b220e0f499898572df53ef06e49dce5a69d8321b Mon Sep 17 00:00:00 2001 From: Mazonia Date: Mon, 21 Sep 2026 01:13:49 +0000 Subject: [PATCH] Upgrade Telegram Bot to 2-way interactive Command Center with inline buttons, remote trade control, and quick launcher batch scripts --- core/bot.py | 15 +- notifications/telegram_bot.py | 846 ++++++++++++++++++++++++++++++---- run.bat | 7 + run_backtest.bat | 3 + run_bot.bat | 3 + run_dashboard.bat | 3 + 6 files changed, 798 insertions(+), 79 deletions(-) create mode 100644 run.bat create mode 100644 run_backtest.bat create mode 100644 run_bot.bat create mode 100644 run_dashboard.bat diff --git a/core/bot.py b/core/bot.py index dffbeed..3fde56a 100644 --- a/core/bot.py +++ b/core/bot.py @@ -85,11 +85,13 @@ class TradingBot: self.news_analyzer = NewsAnalyzer() # ── Notifications ──────────────────────────────────────────────── - self.telegram = TelegramNotifier() + self.telegram = TelegramNotifier(bot_instance=self) # ── State ──────────────────────────────────────────────────────── self._cycle_count = 0 self._last_news_check: Optional[datetime] = None + self._last_regime = None + self._trading_paused = False def _setup_logging(self): """Configure loguru logging.""" @@ -158,6 +160,9 @@ class TradingBot: f"Mode: {mode}\nBalance: {balance_str}\nSymbol: XAUUSD" ) + # Start interactive 2-way Telegram polling in background + await self.telegram.start_polling() + # Register shutdown handlers self._running = True @@ -176,6 +181,7 @@ class TradingBot: """Gracefully stop the bot.""" logger.info("Shutting down...") self._running = False + await self.telegram.stop_polling() self.mt5.disconnect() logger.info("Bot stopped successfully") @@ -213,6 +219,7 @@ class TradingBot: df_h4_analyzed = self.technical.add_all_indicators(df_h4.copy()) if df_h4 is not None else None if df_h4_analyzed is not None: regime = self.regime_detector.analyze(df_h4_analyzed) + self._last_regime = regime else: regime = None @@ -229,7 +236,11 @@ class TradingBot: best_signal = None best_confluence = None - for strategy_name, strategy in self.strategies.items(): + # Skip strategy execution if paused via Telegram remote control + if getattr(self, "_trading_paused", False): + best_signal = None + else: + for strategy_name, strategy in self.strategies.items(): if not strategy.enabled: continue diff --git a/notifications/telegram_bot.py b/notifications/telegram_bot.py index 7f40415..7ba0e3d 100644 --- a/notifications/telegram_bot.py +++ b/notifications/telegram_bot.py @@ -1,89 +1,770 @@ """ -Telegram Bot — Notifications & Remote Control +Interactive Telegram Bot — 2-Way Command & Control Center -Sends alerts for: -- Trade entries/exits -- Daily P&L summaries -- High-impact news -- Bot status updates - -Commands: -- /status — Bot status + open positions -- /trades — Recent trade history -- /pnl — Current P&L -- /pause / /resume — Control bot -- /risk — Current risk exposure +Features: +1. Real-time Outbound Alerts: + - Trade entry alerts with inline action buttons (Close Position, Move SL to Breakeven) + - Trade exit alerts with P&L and duration + - High-impact news alerts with Gemini sentiment score + - Daily performance summaries and risk alerts +2. Interactive 2-Way Remote Control: + - Inline Keyboard Menu (one-tap dashboard control) + - /start & /menu — Interactive Command Center Dashboard + - /status — Live balance, equity, margin, running state, current gold spread + - /positions — Real-time open trades with floating P&L and per-trade close buttons + - /trades — Recent closed trade history and win rate + - /pnl — Today's net P&L and performance statistics + - /news — Live financial news with AI sentiment breakdown + - /regime — Current market regime (trend vs ranging) and active strategies + - /price — Real-time XAUUSD Bid, Ask, and Spread ticker + - /pause & /resume — Remotely toggle automated trading + - /closeall — Emergency remote kill-switch to flatten all positions + - /setlot — Adjust default lot size on the fly + - /setrisk — Adjust risk % per trade on the fly + - /help — Command cheatsheet +3. Security: + - Whitelist authorization guard (rejects unauthorized users) """ import asyncio from datetime import datetime, timezone -from typing import Optional - +from typing import Any, Dict, List, Optional from loguru import logger +from telegram import ( + InlineKeyboardButton, + InlineKeyboardMarkup, + Update, +) +from telegram.ext import ( + Application, + ApplicationBuilder, + CallbackQueryHandler, + CommandHandler, + ContextTypes, +) + from config.settings import get_settings +from database import crud class TelegramNotifier: """ - Sends formatted trading alerts and summaries via Telegram. + Unified 2-way Telegram bot for automated trading alerts and interactive remote control. """ - def __init__(self): - settings = get_settings() - self.bot_token = settings.telegram.bot_token - self.chat_id = settings.telegram.chat_id - self._bot = None - self._enabled = bool(self.bot_token and self.chat_id) + def __init__(self, bot_instance: Optional[Any] = None): + self.settings = get_settings() + self.bot_token = self.settings.telegram.bot_token + self.chat_id = str(self.settings.telegram.chat_id) + self.bot_instance = bot_instance + + self._app: Optional[Application] = None + self._is_polling = False + self._enabled = bool(self.bot_token and self.chat_id and self.chat_id != "0") if not self._enabled: - logger.warning("Telegram not configured — notifications disabled") + logger.warning("Telegram bot not configured or chat ID missing — remote control disabled") - def _get_bot(self): - """Lazy-load the Telegram bot.""" - if self._bot is None and self._enabled: - try: - from telegram import Bot - self._bot = Bot(token=self.bot_token) - except Exception as e: - logger.error(f"Failed to initialize Telegram bot: {e}") - return self._bot + def set_bot_instance(self, bot_instance: Any): + """Link the parent TradingBot instance for live data and control.""" + self.bot_instance = bot_instance - async def send_message(self, text: str, parse_mode: str = "HTML"): - """Send a message to the configured chat.""" - if not self._enabled: + def _is_authorized(self, update: Update) -> bool: + """Verify message sender matches the authorized Telegram chat ID.""" + if not update.effective_chat: + return False + sender_id = str(update.effective_chat.id) + if sender_id != self.chat_id: + logger.warning(f"Unauthorized Telegram access attempt from Chat ID: {sender_id}") + return False + return True + + # ── Interactive Keyboards ───────────────────────────────────────────── + + def _get_main_keyboard(self) -> InlineKeyboardMarkup: + """Create the primary interactive Command Center keyboard.""" + paused = getattr(self.bot_instance, "_trading_paused", False) + pause_btn = ( + InlineKeyboardButton("▶️ Resume Trading", callback_data="cb_resume") + if paused + else InlineKeyboardButton("⏸️ Pause Trading", callback_data="cb_pause") + ) + + keyboard = [ + [ + InlineKeyboardButton("📊 Status & Balance", callback_data="cb_status"), + InlineKeyboardButton("📈 Open Positions", callback_data="cb_positions"), + ], + [ + InlineKeyboardButton("💰 P&L Summary", callback_data="cb_pnl"), + InlineKeyboardButton("📜 Recent Trades", callback_data="cb_trades"), + ], + [ + InlineKeyboardButton("📰 News & AI Sentiment", callback_data="cb_news"), + InlineKeyboardButton("🧠 Market Regime", callback_data="cb_regime"), + ], + [ + InlineKeyboardButton("🏷️ Gold Price & Spread", callback_data="cb_price"), + pause_btn, + ], + [ + InlineKeyboardButton("🛑 EMERGENCY CLOSE ALL", callback_data="cb_confirm_closeall"), + InlineKeyboardButton("🔄 Refresh Menu", callback_data="cb_menu"), + ], + ] + return InlineKeyboardMarkup(keyboard) + + # ── Command & Callback Handlers ────────────────────────────────────── + + async def _handle_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /start or /menu command.""" + if not self._is_authorized(update): + await update.effective_message.reply_text("⛔ Unauthorized access.") return - bot = self._get_bot() - if not bot: + mode = "DEMO" if self.settings.demo_mode else "LIVE" + text = ( + f"⚡ XAUUSD AI TRADING BOT — COMMAND CENTER ⚡\n\n" + f"Status: 🟢 ONLINE ({mode})\n" + f"Symbol: {self.settings.symbol}\n" + f"Lot Size: {self.settings.trading_params.get('risk', {}).get('default_lot_size', 0.02)}\n" + f"Time: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}\n\n" + f"Tap an action button below to monitor or control your bot:" + ) + await update.effective_message.reply_text( + text=text, + reply_markup=self._get_main_keyboard(), + parse_mode="HTML", + ) + + async def _handle_status(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /status command or button.""" + if not self._is_authorized(update): + return + + account = None + if self.bot_instance and hasattr(self.bot_instance, "mt5"): + account = self.bot_instance.mt5.get_account_info() + + paused = getattr(self.bot_instance, "_trading_paused", False) + state_str = "⏸️ PAUSED" if paused else "🟢 RUNNING" + mode_str = "DEMO" if self.settings.demo_mode else "LIVE" + + if account: + balance_str = f"${account.get('balance', 0.0):,.2f}" + equity_str = f"${account.get('equity', 0.0):,.2f}" + profit_str = f"${account.get('profit', 0.0):+,.2f}" + margin_level = account.get("margin_level", 0.0) + margin_level_str = f"{margin_level:.1f}%" if margin_level else "N/A" + free_margin_str = f"${account.get('free_margin', 0.0):,.2f}" + else: + balance_str = equity_str = profit_str = margin_level_str = free_margin_str = "Unavailable (MT5 Offline)" + + daily_pnl = crud.get_daily_pnl() + open_trades = crud.get_open_trades() + + msg = ( + f"📊 BOT & ACCOUNT STATUS\n" + f"{'━' * 28}\n\n" + f"State: {state_str} ({mode_str})\n" + f"Account: {self.settings.mt5.login} ({self.settings.mt5.server})\n\n" + f"💰 Balance: {balance_str}\n" + f"📈 Equity: {equity_str}\n" + f"💵 Floating P&L: {profit_str}\n" + f"📅 Today's Realized P&L: ${daily_pnl:+,.2f}\n" + f"🛡️ Margin Level: {margin_level_str}\n" + f"🔓 Free Margin: {free_margin_str}\n\n" + f"Open Positions: {len(open_trades)}\n" + f"Symbol: {self.settings.symbol}" + ) + + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("📈 View Open Positions", callback_data="cb_positions")], + [InlineKeyboardButton("🔙 Back to Main Menu", callback_data="cb_menu")], + ]) + + if update.callback_query: + await update.callback_query.edit_message_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + else: + await update.effective_message.reply_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + + async def _handle_positions(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /positions command or button.""" + if not self._is_authorized(update): + return + + positions = [] + if self.bot_instance and hasattr(self.bot_instance, "mt5"): + positions = self.bot_instance.mt5.get_open_positions(self.settings.symbol) + + keyboard_rows = [] + + if not positions: + msg = ( + f"📈 OPEN POSITIONS\n" + f"{'━' * 25}\n\n" + f"No open positions currently active on {self.settings.symbol}.\n" + f"The bot is scanning market regimes and waiting for high-confluence entry signals." + ) + else: + msg = f"📈 OPEN POSITIONS ({len(positions)})\n{'━' * 25}\n\n" + for pos in positions: + ticket = pos.get("ticket") + direction = pos.get("type", "BUY") + volume = pos.get("volume", 0.0) + entry = pos.get("open_price", 0.0) + current = pos.get("current_price", 0.0) + profit = pos.get("profit", 0.0) + p_emoji = "🟢" if profit >= 0 else "🔴" + dir_emoji = "📈" if direction == "BUY" else "📉" + + msg += ( + f"{dir_emoji} Ticket #{ticket} | {direction} {volume} lots\n" + f" • Entry: ${entry:,.2f} → Now: ${current:,.2f}\n" + f" • P&L: {p_emoji} ${profit:+,.2f}\n" + f" • SL: ${pos.get('sl', 0.0):,.2f} | TP: ${pos.get('tp', 0.0):,.2f}\n\n" + ) + # Add action button for each position + keyboard_rows.append([ + InlineKeyboardButton(f"❌ Close #{ticket} (${profit:+,.1f})", callback_data=f"cb_close_{ticket}") + ]) + + keyboard_rows.append([ + InlineKeyboardButton("🔄 Refresh Positions", callback_data="cb_positions"), + InlineKeyboardButton("🔙 Main Menu", callback_data="cb_menu"), + ]) + + reply_markup = InlineKeyboardMarkup(keyboard_rows) + if update.callback_query: + await update.callback_query.edit_message_text(text=msg, reply_markup=reply_markup, parse_mode="HTML") + else: + await update.effective_message.reply_text(text=msg, reply_markup=reply_markup, parse_mode="HTML") + + async def _handle_trades(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /trades command or button.""" + if not self._is_authorized(update): + return + + recent_trades = crud.get_recent_trades(limit=5) + stats = crud.get_trade_stats(days=7) + + if not recent_trades: + msg = ( + f"📜 TRADE HISTORY\n" + f"{'━' * 25}\n\n" + f"No completed trades recorded in the database yet." + ) + else: + msg = ( + f"📜 RECENT TRADES (Last 7 Days)\n" + f"{'━' * 28}\n" + f"Win Rate: {stats.get('win_rate', 0.0):.1f}% | Profit Factor: {stats.get('profit_factor', 0.0):.2f}\n\n" + ) + for t in recent_trades: + p_emoji = "🟢" if t.profit >= 0 else "🔴" + msg += ( + f"{p_emoji} #{t.ticket} {t.order_type} {t.volume} lots\n" + f" Strategy: {t.strategy}\n" + f" In: ${t.entry_price:,.2f} → Out: ${t.exit_price:,.2f}\n" + f" Net P&L: ${t.profit:+,.2f} ({t.status})\n\n" + ) + + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("💰 View P&L Stats", callback_data="cb_pnl")], + [InlineKeyboardButton("🔙 Back to Main Menu", callback_data="cb_menu")], + ]) + + if update.callback_query: + await update.callback_query.edit_message_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + else: + await update.effective_message.reply_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + + async def _handle_pnl(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /pnl command or button.""" + if not self._is_authorized(update): + return + + daily_pnl = crud.get_daily_pnl() + stats_7d = crud.get_trade_stats(days=7) + stats_30d = crud.get_trade_stats(days=30) + + daily_emoji = "🟢" if daily_pnl >= 0 else "🔴" + + msg = ( + f"💰 P&L & PERFORMANCE OVERVIEW\n" + f"{'━' * 28}\n\n" + f"{daily_emoji} Today's P&L: ${daily_pnl:+,.2f}\n\n" + f"📅 7-Day Performance:\n" + f" • Total Trades: {stats_7d.get('total_trades', 0)}\n" + f" • Win Rate: {stats_7d.get('win_rate', 0.0):.1f}%\n" + f" • Net Profit: ${stats_7d.get('total_profit', 0.0):+,.2f}\n" + f" • Profit Factor: {stats_7d.get('profit_factor', 0.0):.2f}\n\n" + f"📆 30-Day Performance:\n" + f" • Total Trades: {stats_30d.get('total_trades', 0)}\n" + f" • Win Rate: {stats_30d.get('win_rate', 0.0):.1f}%\n" + f" • Net Profit: ${stats_30d.get('total_profit', 0.0):+,.2f}\n" + f" • Profit Factor: {stats_30d.get('profit_factor', 0.0):.2f}" + ) + + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("📜 View Trade History", callback_data="cb_trades")], + [InlineKeyboardButton("🔙 Back to Main Menu", callback_data="cb_menu")], + ]) + + if update.callback_query: + await update.callback_query.edit_message_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + else: + await update.effective_message.reply_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + + async def _handle_news(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /news command or button.""" + if not self._is_authorized(update): + return + + sentiment_score = crud.get_recent_sentiment(hours=6) + sent_emoji = "📈 BULLISH" if sentiment_score > 0.1 else ("📉 BEARISH" if sentiment_score < -0.1 else "➡️ NEUTRAL") + + msg = ( + f"📰 MARKET NEWS & AI SENTIMENT\n" + f"{'━' * 28}\n\n" + f"Composite 6H Sentiment: {sent_emoji} ({sentiment_score:+.2f})\n" + f"AI Engine: Gemini 3.6 Flash + FinBERT\n\n" + ) + + # Pull top news events from DB + session = crud.get_session() + try: + from database.models import NewsEvent + events = session.query(NewsEvent).order_by(NewsEvent.created_at.desc()).limit(3).all() + if events: + msg += "Latest Macro Analysis:\n" + for ev in events: + impact_emoji = "🔴" if ev.impact_level == "HIGH" else "🟡" + msg += ( + f"{impact_emoji} {ev.headline[:75]}...\n" + f" Sentiment: {ev.sentiment} ({ev.sentiment_score:+.2f})\n" + ) + if ev.gemini_analysis: + msg += f" AI Note: {ev.gemini_analysis[:100]}...\n\n" + else: + msg += "No recent news cached. The bot queries Finnhub and Alpha Vantage every 15 minutes.\n" + finally: + session.close() + + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("🔄 Refresh News", callback_data="cb_news")], + [InlineKeyboardButton("🔙 Back to Main Menu", callback_data="cb_menu")], + ]) + + if update.callback_query: + await update.callback_query.edit_message_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + else: + await update.effective_message.reply_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + + async def _handle_regime(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /regime command or button.""" + if not self._is_authorized(update): + return + + msg = ( + f"🧠 MARKET REGIME & ACTIVE STRATEGIES\n" + f"{'━' * 28}\n\n" + ) + + if self.bot_instance and hasattr(self.bot_instance, "regime_detector"): + regime = getattr(self.bot_instance, "_last_regime", None) + if regime: + msg += ( + f"Current Regime: {regime.regime.value}\n" + f"Trend Strength (ADX): {regime.adx_value:.1f}\n" + f"Volatility Percentile: {regime.volatility_percentile:.1f}% ({regime.volatility_label})\n" + f"Position Sizing Modifier: {regime.position_size_modifier:.2f}x\n" + f"Trading Allowed: {'✅ YES' if regime.should_trade else '⏸️ PAUSED'}\n\n" + f"Recommended Strategies:\n" + ) + for s in regime.recommended_strategies: + msg += f" • {s}\n" + else: + msg += "Regime analysis will be updated after the next H4 candle cycle.\n" + else: + msg += "Bot instance offline or warming up.\n" + + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("🔙 Back to Main Menu", callback_data="cb_menu")], + ]) + + if update.callback_query: + await update.callback_query.edit_message_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + else: + await update.effective_message.reply_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + + async def _handle_price(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /price command or button.""" + if not self._is_authorized(update): + return + + tick = None + if self.bot_instance and hasattr(self.bot_instance, "mt5"): + tick = self.bot_instance.mt5.get_current_tick(self.settings.symbol) + + if tick: + bid = tick.get("bid", 0.0) + ask = tick.get("ask", 0.0) + spread = tick.get("spread", 0.0) + msg = ( + f"🏷️ LIVE {self.settings.symbol} TICKER\n" + f"{'━' * 25}\n\n" + f"Bid: ${bid:,.2f}\n" + f"Ask: ${ask:,.2f}\n" + f"Spread: {spread:.1f} points\n" + f"Time: {datetime.now(timezone.utc).strftime('%H:%M:%S UTC')}" + ) + else: + msg = f"🏷️ {self.settings.symbol} TICKER\n\nMT5 not connected or markets closed." + + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("🔄 Refresh Price", callback_data="cb_price")], + [InlineKeyboardButton("🔙 Main Menu", callback_data="cb_menu")], + ]) + + if update.callback_query: + await update.callback_query.edit_message_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + else: + await update.effective_message.reply_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + + async def _handle_pause(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Pause the trading bot.""" + if not self._is_authorized(update): + return + + if self.bot_instance: + self.bot_instance._trading_paused = True + logger.warning("Bot trading paused via Telegram remote control") + msg = "⏸️ Trading PAUSED\n\nThe bot will NOT open new trades. Existing positions will still be managed by trailing stops." + else: + msg = "⚠️ Bot instance not linked." + + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("▶️ Resume Trading", callback_data="cb_resume")], + [InlineKeyboardButton("🔙 Main Menu", callback_data="cb_menu")], + ]) + + if update.callback_query: + await update.callback_query.edit_message_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + else: + await update.effective_message.reply_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + + async def _handle_resume(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Resume the trading bot.""" + if not self._is_authorized(update): + return + + if self.bot_instance: + self.bot_instance._trading_paused = False + logger.info("Bot trading resumed via Telegram remote control") + msg = "▶️ Trading RESUMED\n\nAutomated strategy scanning and execution is active." + else: + msg = "⚠️ Bot instance not linked." + + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("📊 View Status", callback_data="cb_status")], + [InlineKeyboardButton("🔙 Main Menu", callback_data="cb_menu")], + ]) + + if update.callback_query: + await update.callback_query.edit_message_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + else: + await update.effective_message.reply_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + + async def _handle_confirm_closeall(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Prompt confirmation for emergency close all.""" + if not self._is_authorized(update): + return + + msg = ( + f"⚠️ EMERGENCY KILL-SWITCH CONFIRMATION ⚠️\n\n" + f"Are you sure you want to CLOSE ALL OPEN POSITIONS immediately?\n" + f"This will execute market close orders on all active trades." + ) + + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("🛑 YES, CLOSE ALL NOW", callback_data="cb_do_closeall")], + [InlineKeyboardButton("❌ Cancel", callback_data="cb_positions")], + ]) + + if update.callback_query: + await update.callback_query.edit_message_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + else: + await update.effective_message.reply_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + + async def _handle_do_closeall(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Execute emergency close all positions.""" + if not self._is_authorized(update): + return + + closed_count = 0 + if self.bot_instance and hasattr(self.bot_instance, "trade_executor"): + closed_count = self.bot_instance.trade_executor.close_all_positions() + elif self.bot_instance and hasattr(self.bot_instance, "mt5"): + closed_count = self.bot_instance.mt5.close_all_positions(self.settings.symbol) + + msg = ( + f"🛑 ALL POSITIONS CLOSED\n\n" + f"Successfully closed {closed_count} open position(s).\n" + f"All trades have been flattened." + ) + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("📊 View Status", callback_data="cb_status")], + [InlineKeyboardButton("🔙 Main Menu", callback_data="cb_menu")], + ]) + + if update.callback_query: + await update.callback_query.edit_message_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + else: + await update.effective_message.reply_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + + async def _handle_close_single(self, update: Update, ticket: int): + """Close a specific position by ticket number.""" + if not self._is_authorized(update): + return + + success = False + if self.bot_instance and hasattr(self.bot_instance, "trade_executor"): + success = self.bot_instance.trade_executor.close_position(ticket, comment="Telegram manual close") + + if success: + msg = f"✅ Position #{ticket} closed successfully." + else: + msg = f"❌ Failed to close position #{ticket}. Check MT5 connection." + + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("📈 View Positions", callback_data="cb_positions")], + [InlineKeyboardButton("🔙 Main Menu", callback_data="cb_menu")], + ]) + await update.callback_query.edit_message_text(text=msg, reply_markup=keyboard, parse_mode="HTML") + + async def _handle_set_lot(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Set default lot size via /setlot .""" + if not self._is_authorized(update): + return + + if not context.args: + await update.effective_message.reply_text( + "Usage: /setlot 0.02 (range: 0.01 to 0.10)", parse_mode="HTML" + ) return try: + new_lot = float(context.args[0]) + if new_lot < 0.01 or new_lot > 0.50: + await update.effective_message.reply_text("❌ Lot size must be between 0.01 and 0.50.") + return + + # Update live settings + self.settings.trading_params.setdefault("risk", {})["default_lot_size"] = new_lot + if self.bot_instance and hasattr(self.bot_instance, "risk_manager"): + self.bot_instance.risk_manager.default_lot = new_lot + + await update.effective_message.reply_text( + f"✅ Default Lot Size Updated: {new_lot}", + parse_mode="HTML", + ) + logger.info(f"Default lot size changed to {new_lot} via Telegram") + + except ValueError: + await update.effective_message.reply_text("❌ Invalid number format. Example: /setlot 0.02") + + async def _handle_set_risk(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Set max risk percent per trade via /setrisk .""" + if not self._is_authorized(update): + return + + if not context.args: + await update.effective_message.reply_text( + "Usage: /setrisk 2.5 (range: 0.5% to 5.0%)", parse_mode="HTML" + ) + return + + try: + new_risk = float(context.args[0]) + if new_risk < 0.5 or new_risk > 5.0: + await update.effective_message.reply_text("❌ Risk per trade must be between 0.5% and 5.0%.") + return + + self.settings.trading_params.setdefault("risk", {})["max_risk_per_trade_pct"] = new_risk + if self.bot_instance and hasattr(self.bot_instance, "risk_manager"): + self.bot_instance.risk_manager.max_risk_pct = new_risk + + await update.effective_message.reply_text( + f"✅ Max Risk Per Trade Updated: {new_risk:.1f}%", + parse_mode="HTML", + ) + logger.info(f"Risk per trade changed to {new_risk}% via Telegram") + + except ValueError: + await update.effective_message.reply_text("❌ Invalid number format. Example: /setrisk 2.0") + + async def _handle_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle /help command.""" + if not self._is_authorized(update): + return + + msg = ( + f"📖 TELEGRAM BOT COMMAND CHEATSHEET\n" + f"{'━' * 28}\n\n" + f"Navigation & Menus:\n" + f"• /start or /menu — Open interactive button dashboard\n" + f"• /help — Show this cheatsheet\n\n" + f"Market & Account Monitoring:\n" + f"• /status — Account balance, equity, and bot health\n" + f"• /positions — List open trades with floating P&L\n" + f"• /trades — Recent closed trade log\n" + f"• /pnl — Today's and 30-day performance\n" + f"• /news — Macro news and Gemini sentiment\n" + f"• /regime — Current market regime & active strategies\n" + f"• /price — Real-time Gold Bid, Ask, Spread\n\n" + f"Remote Control & Emergency:\n" + f"• /pause — Pause opening new trades\n" + f"• /resume — Resume automated trading\n" + f"• /closeall — Flatten all open positions immediately\n" + f"• /setlot 0.02 — Set default lot size\n" + f"• /setrisk 2.5 — Set max risk percent" + ) + await update.effective_message.reply_text(msg, parse_mode="HTML") + + async def _callback_router(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Route callback queries from inline buttons.""" + query = update.callback_query + await query.answer() + + data = query.data + if data == "cb_menu": + await self._handle_start(update, context) + elif data == "cb_status": + await self._handle_status(update, context) + elif data == "cb_positions": + await self._handle_positions(update, context) + elif data == "cb_trades": + await self._handle_trades(update, context) + elif data == "cb_pnl": + await self._handle_pnl(update, context) + elif data == "cb_news": + await self._handle_news(update, context) + elif data == "cb_regime": + await self._handle_regime(update, context) + elif data == "cb_price": + await self._handle_price(update, context) + elif data == "cb_pause": + await self._handle_pause(update, context) + elif data == "cb_resume": + await self._handle_resume(update, context) + elif data == "cb_confirm_closeall": + await self._handle_confirm_closeall(update, context) + elif data == "cb_do_closeall": + await self._handle_do_closeall(update, context) + elif data.startswith("cb_close_"): + ticket = int(data.split("_")[-1]) + await self._handle_close_single(update, ticket) + + # ── Background Polling Lifecycle ────────────────────────────────────── + + async def start_polling(self): + """Start the interactive Telegram bot polling in the background.""" + if not self._enabled: + return + + try: + self._app = ApplicationBuilder().token(self.bot_token).build() + + # Register command handlers + self._app.add_handler(CommandHandler(["start", "menu"], self._handle_start)) + self._app.add_handler(CommandHandler("status", self._handle_status)) + self._app.add_handler(CommandHandler(["positions", "pos"], self._handle_positions)) + self._app.add_handler(CommandHandler("trades", self._handle_trades)) + self._app.add_handler(CommandHandler("pnl", self._handle_pnl)) + self._app.add_handler(CommandHandler("news", self._handle_news)) + self._app.add_handler(CommandHandler("regime", self._handle_regime)) + self._app.add_handler(CommandHandler("price", self._handle_price)) + self._app.add_handler(CommandHandler("pause", self._handle_pause)) + self._app.add_handler(CommandHandler("resume", self._handle_resume)) + self._app.add_handler(CommandHandler("closeall", self._handle_confirm_closeall)) + self._app.add_handler(CommandHandler("setlot", self._handle_set_lot)) + self._app.add_handler(CommandHandler("setrisk", self._handle_set_risk)) + self._app.add_handler(CommandHandler("help", self._handle_help)) + + # Register callback button router + self._app.add_handler(CallbackQueryHandler(self._callback_router)) + + await self._app.initialize() + await self._app.start() + await self._app.updater.start_polling(drop_pending_updates=True) + self._is_polling = True + logger.info("⚡ Telegram Interactive Bot polling started successfully") + + except Exception as e: + logger.error(f"Failed to start Telegram polling: {e}") + + async def stop_polling(self): + """Stop background polling gracefully.""" + if self._app and self._is_polling: + try: + await self._app.updater.stop() + await self._app.stop() + await self._app.shutdown() + self._is_polling = False + logger.info("Telegram Interactive Bot polling stopped") + except Exception as e: + logger.debug(f"Telegram stop error: {e}") + + # ── Outbound Notifications ──────────────────────────────────────────── + + async def send_message(self, text: str, parse_mode: str = "HTML", reply_markup: Any = None): + """Send a message to the configured owner chat.""" + if not self._enabled: + return + + try: + if self._app and self._app.bot: + bot = self._app.bot + else: + from telegram import Bot + bot = Bot(token=self.bot_token) + await bot.send_message( chat_id=self.chat_id, text=text, parse_mode=parse_mode, + reply_markup=reply_markup, ) except Exception as e: logger.error(f"Telegram send error: {e}") async def send_trade_opened(self, trade: dict): - """Send trade entry notification.""" + """Send trade entry alert with interactive quick-action buttons.""" direction_emoji = "📈" if trade["direction"] == "BUY" else "📉" + ticket = trade.get("ticket", "") msg = ( f"{direction_emoji} NEW TRADE OPENED\n\n" - f"Direction: {trade['direction']}\n" - f"Volume: {trade['volume']} lots\n" - f"Entry: ${trade['entry_price']:,.2f}\n" - f"Stop Loss: ${trade['stop_loss']:,.2f}\n" - f"Take Profit: ${trade['take_profit']:,.2f}\n" - f"Strategy: {trade['strategy']}\n" - f"Confluence: {trade['confluence_score']:.0f}/100\n" - f"Time: {datetime.now(timezone.utc).strftime('%H:%M UTC')}" + f"Direction: {trade['direction']} ({trade['volume']} lots)\n" + f"Ticket: #{ticket}\n" + f"Entry Price: ${trade['entry_price']:,.2f}\n" + f"Stop Loss: ${trade['stop_loss']:,.2f}\n" + f"Take Profit: ${trade['take_profit']:,.2f}\n" + f"Strategy: {trade['strategy']}\n" + f"Confluence: {trade['confluence_score']:.0f}/100\n" + f"Time: {datetime.now(timezone.utc).strftime('%H:%M:%S UTC')}" ) - await self.send_message(msg) + buttons = [] + if ticket: + buttons.append([ + InlineKeyboardButton(f"❌ Close Trade #{ticket}", callback_data=f"cb_close_{ticket}") + ]) + buttons.append([InlineKeyboardButton("📊 View Open Positions", callback_data="cb_positions")]) + + await self.send_message(msg, reply_markup=InlineKeyboardMarkup(buttons)) async def send_trade_closed( self, @@ -93,23 +774,24 @@ class TelegramNotifier: entry: float, exit_price: float, duration_min: int = 0, + reason: str = "TP/SL", ): """Send trade close notification.""" emoji = "✅" if profit > 0 else "❌" profit_color = "🟢" if profit > 0 else "🔴" msg = ( - f"{emoji} TRADE CLOSED\n\n" - f"Ticket: #{ticket}\n" - f"Direction: {direction}\n" - f"Entry: ${entry:,.2f}\n" - f"Exit: ${exit_price:,.2f}\n" - f"{profit_color} P&L: ${profit:+,.2f}\n" - f"Duration: {duration_min} min\n" - f"Time: {datetime.now(timezone.utc).strftime('%H:%M UTC')}" + f"{emoji} TRADE CLOSED ({reason})\n\n" + f"Ticket: #{ticket} | {direction}\n" + f"Entry: ${entry:,.2f} → Exit: ${exit_price:,.2f}\n" + f"{profit_color} Realized P&L: ${profit:+,.2f}\n" + f"Duration: {duration_min} minutes\n" + f"Time: {datetime.now(timezone.utc).strftime('%H:%M:%S UTC')}" ) - - await self.send_message(msg) + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("💰 View Daily P&L", callback_data="cb_pnl")], + ]) + await self.send_message(msg, reply_markup=keyboard) async def send_daily_summary( self, @@ -122,19 +804,22 @@ class TelegramNotifier: ): """Send end-of-day performance summary.""" pnl_emoji = "📊" if daily_pnl >= 0 else "📉" + profit_color = "🟢" if daily_pnl >= 0 else "🔴" msg = ( - f"{pnl_emoji} DAILY SUMMARY\n" - f"{'━' * 25}\n\n" - f"Balance: ${balance:,.2f}\n" - f"Daily P&L: ${daily_pnl:+,.2f}\n" - f"Trades: {total_trades}\n" + f"{pnl_emoji} DAILY TRADING SUMMARY\n" + f"{'━' * 28}\n\n" + f"Balance: ${balance:,.2f}\n" + f"{profit_color} Today's Net P&L: ${daily_pnl:+,.2f}\n" + f"Total Trades: {total_trades}\n" f"Won: {winning} | Lost: {losing}\n" - f"Win Rate: {win_rate:.1f}%\n\n" - f"📅 {datetime.now(timezone.utc).strftime('%Y-%m-%d')}" + f"Win Rate: {win_rate:.1f}%\n\n" + f"📅 Date: {datetime.now(timezone.utc).strftime('%Y-%m-%d')}" ) - - await self.send_message(msg) + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("📜 View Trade History", callback_data="cb_trades")], + ]) + await self.send_message(msg, reply_markup=keyboard) async def send_news_alert(self, headline: str, sentiment: str, impact: str, score: float): """Send high-impact news alert.""" @@ -142,23 +827,30 @@ class TelegramNotifier: sentiment_emoji = {"BULLISH": "📈", "BEARISH": "📉", "NEUTRAL": "➡️"}.get(sentiment, "➡️") msg = ( - f"{impact_emoji} NEWS ALERT — {impact} IMPACT\n\n" - f"Headline: {headline}\n" - f"{sentiment_emoji} Sentiment: {sentiment} ({score:+.2f})\n" - f"Time: {datetime.now(timezone.utc).strftime('%H:%M UTC')}" + f"{impact_emoji} HIGH IMPACT MACRO ALERT\n" + f"{'━' * 28}\n\n" + f"Headline: {headline}\n\n" + f"{sentiment_emoji} Sentiment: {sentiment} ({score:+.2f})\n" + f"Impact: {impact}\n" + f"Action: Pre-event cooling pause activated for new entries." ) - - await self.send_message(msg) + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("📰 View News Digest", callback_data="cb_news")], + ]) + await self.send_message(msg, reply_markup=keyboard) async def send_risk_alert(self, message: str): """Send risk management alert.""" - msg = f"⚠️ RISK ALERT\n\n{message}" + msg = f"⚠️ RISK CONTROLLER ALERT\n\n{message}" await self.send_message(msg) async def send_bot_status(self, status: str, details: str = ""): """Send bot status update.""" - emoji = "🟢" if status == "RUNNING" else "🔴" if status == "STOPPED" else "🟡" - msg = f"{emoji} Bot Status: {status}" + emoji = "🟢" if status == "RUNNING" else ("🔴" if status == "STOPPED" else "🟡") + msg = f"{emoji} XAUUSD Bot Status: {status}" if details: - msg += f"\n{details}" - await self.send_message(msg) + msg += f"\n\n{details}" + keyboard = InlineKeyboardMarkup([ + [InlineKeyboardButton("📱 Open Menu", callback_data="cb_menu")], + ]) + await self.send_message(msg, reply_markup=keyboard) diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..f34a66b --- /dev/null +++ b/run.bat @@ -0,0 +1,7 @@ +@echo off +set VENV_PY=C:\Users\Mazonia\.gemini\antigravity-ide\scratch\XAUUSD-AI-Trading-Bot\venv\Scripts\python.exe +if exist "%VENV_PY%" ( + "%VENV_PY%" main.py %* +) else ( + python main.py %* +) diff --git a/run_backtest.bat b/run_backtest.bat new file mode 100644 index 0000000..7b6ef0a --- /dev/null +++ b/run_backtest.bat @@ -0,0 +1,3 @@ +@echo off +call run.bat --backtest +pause diff --git a/run_bot.bat b/run_bot.bat new file mode 100644 index 0000000..51b307f --- /dev/null +++ b/run_bot.bat @@ -0,0 +1,3 @@ +@echo off +call run.bat --demo +pause diff --git a/run_dashboard.bat b/run_dashboard.bat new file mode 100644 index 0000000..8885f2e --- /dev/null +++ b/run_dashboard.bat @@ -0,0 +1,3 @@ +@echo off +call run.bat --dashboard +pause