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 <PAIR> <TF> command for standalone chart view Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||||
@@ -11,6 +11,7 @@ from telegram.ext import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from config import SUPPORTED_PAIRS, SUPPORTED_TIMEFRAMES, TELEGRAM_TOKEN
|
from config import SUPPORTED_PAIRS, SUPPORTED_TIMEFRAMES, TELEGRAM_TOKEN
|
||||||
|
from charts import generate as generate_chart
|
||||||
from indicators import bollinger, macd, rsi
|
from indicators import bollinger, macd, rsi
|
||||||
from signals import HTF_OF, analyze, emoji
|
from signals import HTF_OF, analyze, emoji
|
||||||
from twelvedata import fetch_ohlc
|
from twelvedata import fetch_ohlc
|
||||||
@@ -31,7 +32,8 @@ WELCOME = (
|
|||||||
"/rsi <PAIR> <TF> — RSI value\n"
|
"/rsi <PAIR> <TF> — RSI value\n"
|
||||||
"/macd <PAIR> <TF> — MACD line/signal/histogram\n"
|
"/macd <PAIR> <TF> — MACD line/signal/histogram\n"
|
||||||
"/bb <PAIR> <TF> — Bollinger Bands\n"
|
"/bb <PAIR> <TF> — Bollinger Bands\n"
|
||||||
"/signal <PAIR> <TF> — multi-indicator weighted verdict with HTF trend filter\n"
|
"/signal <PAIR> <TF> — multi-indicator weighted verdict with chart\n"
|
||||||
|
"/chart <PAIR> <TF> — candlestick chart with EMA/BB/RSI\n"
|
||||||
"/help — this message"
|
"/help — this message"
|
||||||
) + DISCLAIMER
|
) + DISCLAIMER
|
||||||
|
|
||||||
@@ -131,6 +133,18 @@ def _format_reading(r) -> str:
|
|||||||
return f"• {r.name}: {r.value} ({sign}{r.contribution})"
|
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:
|
async def signal_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
|
||||||
try:
|
try:
|
||||||
pair, tf = _parse_pair_tf(ctx.args)
|
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"]:
|
if report["notes"]:
|
||||||
lines += ["", *[f"_{n}_" for n in 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:
|
except Exception as e:
|
||||||
logger.exception("signal_cmd failed")
|
logger.exception("signal_cmd failed")
|
||||||
await _reply(update, f"Error: {e}")
|
await _reply(update, f"Error: {e}")
|
||||||
@@ -191,7 +211,8 @@ BOT_COMMANDS: list[BotCommand] = [
|
|||||||
BotCommand("rsi", "RSI(14) — /rsi EURUSD 1h"),
|
BotCommand("rsi", "RSI(14) — /rsi EURUSD 1h"),
|
||||||
BotCommand("macd", "MACD(12,26,9) — /macd EURUSD 1h"),
|
BotCommand("macd", "MACD(12,26,9) — /macd EURUSD 1h"),
|
||||||
BotCommand("bb", "Bollinger Bands(20,2) — /bb 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("macd", macd_cmd))
|
||||||
app.add_handler(CommandHandler("bb", bb_cmd))
|
app.add_handler(CommandHandler("bb", bb_cmd))
|
||||||
app.add_handler(CommandHandler("signal", signal_cmd))
|
app.add_handler(CommandHandler("signal", signal_cmd))
|
||||||
|
app.add_handler(CommandHandler("chart", chart_cmd))
|
||||||
logger.info("Bot starting...")
|
logger.info("Bot starting...")
|
||||||
app.run_polling()
|
app.run_polling()
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ python-telegram-bot>=21.6
|
|||||||
httpx>=0.27.2
|
httpx>=0.27.2
|
||||||
pandas>=2.2.3
|
pandas>=2.2.3
|
||||||
python-dotenv>=1.0.1
|
python-dotenv>=1.0.1
|
||||||
|
mplfinance>=0.12.10b0
|
||||||
|
matplotlib>=3.9.0
|
||||||
|
|||||||
Reference in New Issue
Block a user