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