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()