"""Telegram bot entry point and command handlers.""" from __future__ import annotations import logging from telegram import BotCommand, Update from telegram.ext import ( Application, CommandHandler, ContextTypes, ) from config import SUPPORTED_PAIRS, SUPPORTED_TIMEFRAMES, TELEGRAM_TOKEN from charts import generate as generate_chart from indicators import bollinger, macd, rsi from signals import HTF_OF, analyze, ascii_indicator, to_pips from twelvedata import fetch_ohlc logging.basicConfig( format="%(asctime)s %(levelname)s %(name)s: %(message)s", level=logging.INFO, ) logger = logging.getLogger(__name__) DISCLAIMER = "\n\n_Educational only. Not financial advice._" WELCOME = ( "*Forex Indicator Bot*\n\n" "Commands:\n" "/pairs - list supported pairs/timeframes\n" "/price - latest close (e.g. /price EURUSD)\n" "/rsi - RSI value\n" "/macd - MACD line/signal/histogram\n" "/bb - Bollinger Bands\n" "/signal - multi-indicator weighted verdict with chart\n" "/chart - candlestick chart with EMA/BB/RSI\n" "/help - this message" ) + DISCLAIMER def _parse_pair_tf(args: list[str]) -> tuple[str, str]: if len(args) != 2: raise ValueError("Usage: ") pair, tf = args[0].upper(), args[1].lower() if tf == "1d": tf = "1day" if pair not in SUPPORTED_PAIRS: raise ValueError(f"Unknown pair '{pair}'. Try /pairs.") if tf not in SUPPORTED_TIMEFRAMES: raise ValueError(f"Unknown timeframe '{tf}'. Try /pairs.") return pair, tf async def _reply(update: Update, text: str) -> None: await update.message.reply_text(text + DISCLAIMER, parse_mode="Markdown") async def start(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None: await update.message.reply_text(WELCOME, parse_mode="Markdown") async def pairs_cmd(update: Update, _: ContextTypes.DEFAULT_TYPE) -> None: text = ( "*Pairs:* " + ", ".join(SUPPORTED_PAIRS) + "\n*Timeframes:* " + ", ".join(SUPPORTED_TIMEFRAMES) ) await _reply(update, text) async def price_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: try: if len(ctx.args) != 1: raise ValueError("Usage: /price e.g. /price EURUSD") pair = ctx.args[0].upper() if pair not in SUPPORTED_PAIRS: raise ValueError(f"Unknown pair '{pair}'. Try /pairs.") df = await fetch_ohlc(pair, "1h", size=50) last = df["close"].iloc[-1] ts = df.index[-1].strftime("%Y-%m-%d %H:%M UTC") await _reply(update, f"*{pair}* close: `{last:.5f}`\n_{ts}_") except Exception as e: logger.exception("price_cmd failed") await _reply(update, f"Error: {e}") async def rsi_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: try: pair, tf = _parse_pair_tf(ctx.args) df = await fetch_ohlc(pair, tf) await _reply(update, f"*RSI(14)* {pair} {tf}: `{rsi(df):.2f}`") except Exception as e: logger.exception("rsi_cmd failed") await _reply(update, f"Error: {e}") async def macd_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: try: pair, tf = _parse_pair_tf(ctx.args) df = await fetch_ohlc(pair, tf) m = macd(df) text = ( f"*MACD* {pair} {tf}\n" f"MACD: `{m['macd']:.5f}`\n" f"Signal: `{m['signal']:.5f}`\n" f"Hist: `{m['hist']:.5f}`" ) await _reply(update, text) except Exception as e: logger.exception("macd_cmd failed") await _reply(update, f"Error: {e}") async def bb_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: try: pair, tf = _parse_pair_tf(ctx.args) df = await fetch_ohlc(pair, tf) b = bollinger(df) text = ( f"*Bollinger(20,2)* {pair} {tf}\n" f"Upper: `{b['upper']:.5f}`\n" f"Mid: `{b['mid']:.5f}`\n" f"Lower: `{b['lower']:.5f}`\n" f"Price: `{b['price']:.5f}`" ) await _reply(update, text) except Exception as e: logger.exception("bb_cmd failed") await _reply(update, f"Error: {e}") def _fmt_reading(r) -> str: """Format one indicator row. Shows: signal (raw) × weight → contribution e.g. +1 × 3 → +3 HTF EMA 20/50: strong bullish This makes it immediately clear that the number after → is (raw signal strength) × (importance weight), not a raw count. """ sign = "+" if r.raw > 0 else "" raw_str = f"{sign}{r.raw}" csign = "+" if r.contribution > 0 else "" contrib = f"{csign}{r.contribution}" return ( f" `{raw_str:>3} × {r.weight} → {contrib:>3}`" f" {r.name}: {r.value}" ) def _score_bar(score: int, max_score: int, width: int = 10) -> str: """Return a filled ASCII bar showing directional strength. e.g. score=+8, max=20 -> '####------' We use a single-sided bar: filled squares show absolute strength. """ filled = round(abs(score) / max_score * width) bar = "#" * filled + "-" * (width - filled) return bar def _score_label(score: int, max_score: int) -> str: """Translate numeric score into a plain-English strength phrase.""" pct = abs(score) / max_score * 100 if pct >= 80: return "very strong" if pct >= 50: return "strong" if pct >= 25: return "moderate" return "weak" def _fmt_group(readings, group: str, header: str) -> list[str]: rows = [r for r in readings if r.group == group] if not rows: return [] return [header] + [_fmt_reading(r) for r in rows] def _fmt_price(pair: str, value: float) -> str: """5 decimal places for FX, 2 for XAU.""" return f"{value:.2f}" if pair == "XAUUSD" else f"{value:.5f}" async def chart_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: try: pair, tf = _parse_pair_tf(ctx.args) df = await fetch_ohlc(pair, tf) img = generate_chart(df, pair, tf) caption = f"*{pair}* {tf} - candlestick | EMA20/50 | BB(20,2) | RSI(14)" + DISCLAIMER await update.message.reply_photo(img, caption=caption, parse_mode="Markdown") except Exception as e: logger.exception("chart_cmd failed") await _reply(update, f"Error: {e}") async def signal_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: try: pair, tf = _parse_pair_tf(ctx.args) df = await fetch_ohlc(pair, tf) htf = HTF_OF.get(tf) htf_df = await fetch_ohlc(pair, htf) if htf and htf != tf else None report = analyze(df, htf_df) price = report["price"] atr_val = report["atr"] verdict = report["verdict"] ts = report["timestamp"].strftime("%Y-%m-%d %H:%M UTC") pips = to_pips(pair, atr_val) lines: list[str] = [ f"[SIGNAL] *{pair} {tf}* | HTF: {htf or 'n/a'}", f"`{_fmt_price(pair, price)}` | " f"ATR `{_fmt_price(pair, atr_val)}` ({pips} pips) | _{ts}_", "", *_fmt_group(report["readings"], "trend", f"*-- TREND* (ADX {report['adx_label']}" + (" (!) scores halved" if report["adx_weak"] else "") + ")"), "", *_fmt_group(report["readings"], "momentum", "*-- MOMENTUM*"), "", *_fmt_group(report["readings"], "volatility", "*-- VOLATILITY*"), "", # Score line — bar + plain-English label so the number has context f"`[{_score_bar(report['score'], report['max_score'])}]` " f"{ascii_indicator(verdict)} *{verdict}*", f"Score `{report['score']:+d}` of +/-`{report['max_score']}` " f"({_score_label(report['score'], report['max_score'])} signal, " f"{report['confidence']}% of max)", ] if report["context"]: lines += ["", f"> _{report['context']}_"] sl_dist = 1.5 * atr_val tp_dist = 2.5 * atr_val if verdict in ("BUY", "STRONG BUY"): sl = _fmt_price(pair, price - sl_dist) tp = _fmt_price(pair, price + tp_dist) lines += [ "", f"_ATR-based zones:_ SL `{sl}` | TP `{tp}`", ] elif verdict in ("SELL", "STRONG SELL"): sl = _fmt_price(pair, price + sl_dist) tp = _fmt_price(pair, price - tp_dist) lines += [ "", f"_ATR-based zones:_ SL `{sl}` | TP `{tp}`", ] caption = "\n".join(lines) + DISCLAIMER img = generate_chart(df, pair, tf, verdict=verdict, confidence=report["confidence"]) await update.message.reply_photo(img, caption=caption, parse_mode="Markdown") except Exception as e: logger.exception("signal_cmd failed") await _reply(update, f"Error: {e}") BOT_COMMANDS: list[BotCommand] = [ BotCommand("start", "Show welcome and command list"), BotCommand("help", "Show welcome and command list"), BotCommand("pairs", "List supported pairs and timeframes"), BotCommand("price", "Latest close price - /price EURUSD"), BotCommand("rsi", "RSI(14) - /rsi EURUSD 1h"), BotCommand("macd", "MACD(12,26,9) - /macd EURUSD 1h"), BotCommand("bb", "Bollinger Bands(20,2) - /bb EURUSD 1h"), BotCommand("signal", "Multi-indicator verdict + chart - /signal EURUSD 1h"), BotCommand("chart", "Candlestick chart with EMA/BB/RSI - /chart EURUSD 1h"), ] async def _post_init(app: Application) -> None: await app.bot.set_my_commands(BOT_COMMANDS) logger.info("Registered %d bot commands with Telegram", len(BOT_COMMANDS)) def main() -> None: """Run the bot.""" app = ( Application.builder() .token(TELEGRAM_TOKEN) .post_init(_post_init) .build() ) app.add_handler(CommandHandler("start", start)) app.add_handler(CommandHandler("help", start)) app.add_handler(CommandHandler("pairs", pairs_cmd)) app.add_handler(CommandHandler("price", price_cmd)) app.add_handler(CommandHandler("rsi", rsi_cmd)) app.add_handler(CommandHandler("macd", macd_cmd)) app.add_handler(CommandHandler("bb", bb_cmd)) app.add_handler(CommandHandler("signal", signal_cmd)) app.add_handler(CommandHandler("chart", chart_cmd)) logger.info("Bot starting...") app.run_polling() if __name__ == "__main__": main()