Files
Forex_bot/charts.py
T
Harwinder de96af798c 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>
2026-05-01 16:54:56 -04:00

122 lines
3.9 KiB
Python

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