From de96af798c6ff6efc398fca0cb3e7c294c9b8765 Mon Sep 17 00:00:00 2001 From: Harwinder Date: Fri, 1 May 2026 16:54:56 -0400 Subject: [PATCH] Add chart generation: /chart command + /signal sends chart - charts.py: candlestick (mplfinance, dark theme) with EMA20/50 overlay, Bollinger Bands, RSI subplot and 30/70 reference lines - /signal now sends chart image with analysis as caption - New /chart command for standalone chart view Co-Authored-By: Claude Sonnet 4.6 --- charts.py | 121 +++++++++++++++++++++++++++++++++++++++++++++++ main.py | 28 +++++++++-- requirements.txt | 2 + 3 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 charts.py diff --git a/charts.py b/charts.py new file mode 100644 index 0000000..8d93f89 --- /dev/null +++ b/charts.py @@ -0,0 +1,121 @@ +"""Chart generation — produces a PNG bytes object suitable for Telegram reply_photo.""" +from __future__ import annotations + +import io + +import matplotlib +matplotlib.use("Agg") # non-interactive backend — must be set before other imports + +import matplotlib.pyplot as plt +import matplotlib.ticker as ticker +import mplfinance as mpf +import pandas as pd + + +_STYLE = mpf.make_mpf_style( + base_mpf_style="nightclouds", + rc={ + "font.size": 9, + "axes.titlesize": 11, + "axes.labelsize": 8, + }, + facecolor="#1a1a2e", + figcolor="#1a1a2e", + gridcolor="#2a2a3e", +) + + +def _rsi_series(close: pd.Series, period: int = 14) -> pd.Series: + delta = close.diff() + gain = delta.clip(lower=0.0) + loss = -delta.clip(upper=0.0) + avg_gain = gain.ewm(alpha=1.0 / period, adjust=False).mean() + avg_loss = loss.ewm(alpha=1.0 / period, adjust=False).mean() + rs = avg_gain / avg_loss.replace(0.0, pd.NA) + return (100.0 - (100.0 / (1.0 + rs))).fillna(100.0) + + +def generate( + df: pd.DataFrame, + pair: str, + tf: str, + verdict: str = "", + confidence: int = 0, + candles: int = 80, +) -> bytes: + """Render a candlestick chart with EMA/BB overlays and RSI panel. + + Returns raw PNG bytes for direct use with Telegram reply_photo. + """ + # Compute indicators on full df (warmup), then slice for display + close = df["close"] + ema20_full = close.ewm(span=20, adjust=False).mean() + ema50_full = close.ewm(span=50, adjust=False).mean() + mid_full = close.rolling(20).mean() + std_full = close.rolling(20).std(ddof=0) + bb_upper_full = mid_full + 2.0 * std_full + bb_lower_full = mid_full - 2.0 * std_full + rsi_full = _rsi_series(close) + + n = min(candles, len(df)) + data = df.tail(n).copy() + data.columns = [c.capitalize() for c in data.columns] # mplfinance expects Open/High/Low/Close + + ema20 = ema20_full.tail(n).values + ema50 = ema50_full.tail(n).values + bb_upper = bb_upper_full.tail(n).values + bb_lower = bb_lower_full.tail(n).values + rsi_vals = rsi_full.tail(n).values + + addplots = [ + mpf.make_addplot(ema20, color="#00bfff", width=1.4), + mpf.make_addplot(ema50, color="#ffa500", width=1.4), + mpf.make_addplot(bb_upper, color="#888888", width=0.8, linestyle="--", alpha=0.7), + mpf.make_addplot(bb_lower, color="#888888", width=0.8, linestyle="--", alpha=0.7), + mpf.make_addplot(rsi_vals, panel=1, color="#b388ff", width=1.2, ylabel="RSI"), + ] + + verdict_label = f" {verdict} {confidence}%" if verdict else "" + title = f"{pair} {tf}{verdict_label}" + + fig, axes = mpf.plot( + data, + type="candle", + style=_STYLE, + addplot=addplots, + volume=False, + panel_ratios=(3, 1), + title=title, + returnfig=True, + figsize=(11, 6.5), + tight_layout=False, + ) + + # axes: [price_ax, price_twin, rsi_ax, ...] + rsi_ax = axes[2] + rsi_ax.axhline(70, color="#ff5252", linewidth=0.8, linestyle="--", alpha=0.8) + rsi_ax.axhline(50, color="#777777", linewidth=0.5, linestyle="--", alpha=0.5) + rsi_ax.axhline(30, color="#69f0ae", linewidth=0.8, linestyle="--", alpha=0.8) + rsi_ax.set_ylim(0, 100) + rsi_ax.yaxis.set_major_locator(ticker.MultipleLocator(20)) + + # EMA legend on price panel + price_ax = axes[0] + price_ax.plot([], [], color="#00bfff", linewidth=1.4, label="EMA 20") + price_ax.plot([], [], color="#ffa500", linewidth=1.4, label="EMA 50") + price_ax.plot([], [], color="#888888", linewidth=0.8, linestyle="--", label="BB(20,2)") + price_ax.legend( + loc="upper left", + fontsize=7, + facecolor="#1a1a2e", + edgecolor="#444466", + labelcolor="white", + ) + + fig.subplots_adjust(left=0.08, right=0.97, top=0.92, bottom=0.10, hspace=0.05) + + buf = io.BytesIO() + fig.savefig(buf, format="png", dpi=130, facecolor=fig.get_facecolor()) + plt.close(fig) + buf.seek(0) + return buf.read() diff --git a/main.py b/main.py index e23a0a8..df582bc 100644 --- a/main.py +++ b/main.py @@ -11,6 +11,7 @@ from telegram.ext import ( ) 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, emoji from twelvedata import fetch_ohlc @@ -31,7 +32,8 @@ WELCOME = ( "/rsi — RSI value\n" "/macd — MACD line/signal/histogram\n" "/bb — Bollinger Bands\n" - "/signal — multi-indicator weighted verdict with HTF trend filter\n" + "/signal — multi-indicator weighted verdict with chart\n" + "/chart — candlestick chart with EMA/BB/RSI\n" "/help — this message" ) + DISCLAIMER @@ -131,6 +133,18 @@ def _format_reading(r) -> str: return f"• {r.name}: {r.value} ({sign}{r.contribution})" +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) @@ -177,7 +191,13 @@ async def signal_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: if report["notes"]: lines += ["", *[f"_{n}_" for n in report["notes"]]] - await _reply(update, "\n".join(lines)) + caption = "\n".join(lines) + DISCLAIMER + img = generate_chart( + df, pair, tf, + verdict=report["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}") @@ -191,7 +211,8 @@ BOT_COMMANDS: list[BotCommand] = [ 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 — /signal EURUSD 1h"), + BotCommand("signal", "Multi-indicator verdict + chart — /signal EURUSD 1h"), + BotCommand("chart", "Candlestick chart with EMA/BB/RSI — /chart EURUSD 1h"), ] @@ -216,6 +237,7 @@ def main() -> None: 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() diff --git a/requirements.txt b/requirements.txt index 9ed6406..1564c81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ python-telegram-bot>=21.6 httpx>=0.27.2 pandas>=2.2.3 python-dotenv>=1.0.1 +mplfinance>=0.12.10b0 +matplotlib>=3.9.0