From 9f4a720e300cbf2fe13f5ca606923d830699d305 Mon Sep 17 00:00:00 2001 From: Harwinder Date: Fri, 1 May 2026 17:11:19 -0400 Subject: [PATCH] Refine signal formatting, simplify indicators, and fix chart layout --- charts.py | 83 ++++++++++++------ main.py | 162 +++++++++++++++++++++++------------ signals.py | 241 +++++++++++++++++++++++++++++++++++++---------------- 3 files changed, 337 insertions(+), 149 deletions(-) diff --git a/charts.py b/charts.py index 8d93f89..d705403 100644 --- a/charts.py +++ b/charts.py @@ -9,6 +9,7 @@ matplotlib.use("Agg") # non-interactive backend — must be set before other im import matplotlib.pyplot as plt import matplotlib.ticker as ticker import mplfinance as mpf +import numpy as np import pandas as pd @@ -35,6 +36,24 @@ def _rsi_series(close: pd.Series, period: int = 14) -> pd.Series: return (100.0 - (100.0 / (1.0 + rs))).fillna(100.0) +def _price_ylim(data: pd.DataFrame, pad_pct: float = 0.15) -> tuple[float, float]: + """Return a padded Y-axis range so candle bodies/wicks are never clipped. + + Without this, mplfinance sometimes auto-zooms too tight and the candles + appear as flat lines because the visible high-low range is a tiny fraction + of the axis height. + + pad_pct=0.15 adds 15 % of the visible price range above and below. + """ + lo = float(data["Low"].min()) + hi = float(data["High"].max()) + rng = hi - lo + if rng == 0: + rng = hi * 0.01 # fallback for perfectly flat data + pad = rng * pad_pct + return lo - pad, hi + pad + + def generate( df: pd.DataFrame, pair: str, @@ -46,52 +65,65 @@ def generate( """Render a candlestick chart with EMA/BB overlays and RSI panel. Returns raw PNG bytes for direct use with Telegram reply_photo. + + Key fix: we explicitly set the price-panel Y limits with padding so + candle bodies always take up the full vertical space and are never crushed. """ - # Compute indicators on full df (warmup), then slice for display + # ── Compute indicators on full df for warm-up, 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) + 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) + rsi_full = _rsi_series(close) - n = min(candles, len(df)) + 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 + # mplfinance requires capitalised column names + data.columns = [c.capitalize() for c in data.columns] - ema20 = ema20_full.tail(n).values - ema50 = ema50_full.tail(n).values + 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 + # ── Determine padded price range BEFORE plotting ── + ylim = _price_ylim(data) + 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"), + mpf.make_addplot(ema20, color="#00bfff", width=1.5), + mpf.make_addplot(ema50, color="#ffa500", width=1.5), + mpf.make_addplot(bb_upper, color="#888888", width=0.9, + linestyle="--", alpha=0.7), + mpf.make_addplot(bb_lower, color="#888888", width=0.9, + 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}" + # ── Plot — taller figure + tight_layout gives price panel full room ── fig, axes = mpf.plot( data, type="candle", style=_STYLE, addplot=addplots, volume=False, - panel_ratios=(3, 1), + panel_ratios=(4, 1), # give price panel even more vertical share title=title, returnfig=True, - figsize=(11, 6.5), - tight_layout=False, + figsize=(12, 7), # taller figure so candles breathe + tight_layout=False, # must be False to avoid mplfinance spurious vertical line bug + ylim=ylim, # ← the critical fix: explicit padded range ) - # axes: [price_ax, price_twin, rsi_ax, ...] + # ── RSI panel decoration ── + # axes order: [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) @@ -99,11 +131,12 @@ def generate( rsi_ax.set_ylim(0, 100) rsi_ax.yaxis.set_major_locator(ticker.MultipleLocator(20)) - # EMA legend on price panel + # ── EMA / BB 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.plot([], [], color="#00bfff", linewidth=1.5, label="EMA 20 (short-term trend)") + price_ax.plot([], [], color="#ffa500", linewidth=1.5, label="EMA 50 (long-term trend)") + price_ax.plot([], [], color="#888888", linewidth=0.9, linestyle="--", + label="Bollinger Bands") price_ax.legend( loc="upper left", fontsize=7, @@ -112,10 +145,10 @@ def generate( labelcolor="white", ) - fig.subplots_adjust(left=0.08, right=0.97, top=0.92, bottom=0.10, hspace=0.05) + fig.subplots_adjust(left=0.08, right=0.92, top=0.92, bottom=0.10, hspace=0.05) buf = io.BytesIO() - fig.savefig(buf, format="png", dpi=130, facecolor=fig.get_facecolor()) + fig.savefig(buf, format="png", dpi=140, facecolor=fig.get_facecolor()) plt.close(fig) buf.seek(0) return buf.read() diff --git a/main.py b/main.py index df582bc..41ce2eb 100644 --- a/main.py +++ b/main.py @@ -13,7 +13,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 signals import HTF_OF, analyze, ascii_indicator, to_pips from twelvedata import fetch_ohlc logging.basicConfig( @@ -27,14 +27,14 @@ 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" + "/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 @@ -128,9 +128,57 @@ async def bb_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: await _reply(update, f"Error: {e}") -def _format_reading(r) -> str: - sign = "+" if r.contribution > 0 else "" - return f"• {r.name}: {r.value} ({sign}{r.contribution})" +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: @@ -138,7 +186,7 @@ async def chart_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: 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 + 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") @@ -154,49 +202,55 @@ async def signal_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: 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) - sl_dist = 1.5 * report["atr"] - tp_dist = 2.5 * report["atr"] - price = report["price"] - - lines = [ - f"*Signal* {pair} {tf} (HTF: {htf or 'n/a'})", - f"Price: `{price:.5f}` ATR(14): `{report['atr']:.5f}`", + 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}_", "", - "*Indicators:*", - *[_format_reading(r) for r in report["readings"]], + *_fmt_group(report["readings"], "trend", + f"*-- TREND* (ADX {report['adx_label']}" + + (" (!) scores halved" if report["adx_weak"] else "") + ")"), "", - f"Score: `{report['score']:+d} / ±{report['max_score']}`", - f"*Verdict:* {emoji(report['verdict'])} *{report['verdict']}*" - f" (confidence {report['confidence']}%)", + *_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["verdict"] in ("BUY", "STRONG BUY"): - lines += [ - "", - f"_Suggested zones (ATR-based):_", - f"Entry near `{price:.5f}`", - f"Stop ≈ `{price - sl_dist:.5f}` ({sl_dist:.5f} below)", - f"Target ≈ `{price + tp_dist:.5f}` ({tp_dist:.5f} above)", - ] - elif report["verdict"] in ("SELL", "STRONG SELL"): - lines += [ - "", - f"_Suggested zones (ATR-based):_", - f"Entry near `{price:.5f}`", - f"Stop ≈ `{price + sl_dist:.5f}` ({sl_dist:.5f} above)", - f"Target ≈ `{price - tp_dist:.5f}` ({tp_dist:.5f} below)", - ] + if report["context"]: + lines += ["", f"> _{report['context']}_"] - if report["notes"]: - lines += ["", *[f"_{n}_" for n in report["notes"]]] + 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=report["verdict"], - confidence=report["confidence"], - ) + 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") @@ -207,12 +261,12 @@ 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"), + 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"), ] diff --git a/signals.py b/signals.py index a028cf4..09f4b93 100644 --- a/signals.py +++ b/signals.py @@ -1,7 +1,7 @@ """Signal scoring engine. Each indicator contributes a score in [-2..+2] (sell..buy) with a weight. -Trend scores are gated by ADX (weak trend ⇒ halved). The weighted sum is +Trend scores are gated by ADX (weak trend => halved). The weighted sum is mapped to a verdict and confidence percentage. """ from __future__ import annotations @@ -15,92 +15,114 @@ from indicators import adx, atr, bollinger, ema_pair, macd, rsi, stochastic # Weights — tuned so HTF trend dominates and momentum confirms. W_HTF_TREND = 3 W_LTF_TREND = 2 -W_MACD = 2 -W_RSI = 1 -W_STOCH = 1 -W_BB = 1 -MAX_SCORE = 2 * (W_HTF_TREND + W_LTF_TREND + W_MACD + W_RSI + W_STOCH + W_BB) # 20 +W_MACD = 2 +W_RSI = 1 +W_STOCH = 1 +W_BB = 1 +MAX_SCORE = 2 * (W_HTF_TREND + W_LTF_TREND + W_MACD + W_RSI + W_STOCH + W_BB) # 20 # Higher-timeframe mapping for the trend filter. HTF_OF: dict[str, str] = { "15min": "1h", - "1h": "4h", - "4h": "1day", - "1day": "1day", + "1h": "4h", + "4h": "1day", + "1day": "1day", } +# Pip size per pair (price per pip). +PIP_SIZE: dict[str, float] = { + "USDJPY": 0.01, + "XAUUSD": 0.10, +} +_DEFAULT_PIP = 0.0001 + + +def pip_size(pair: str) -> float: + return PIP_SIZE.get(pair.upper(), _DEFAULT_PIP) + + +def to_pips(pair: str, value: float) -> float: + return round(value / pip_size(pair), 1) + @dataclass class Reading: name: str - value: str # display string for the user - raw: int # -2..+2 + value: str # human-readable label + raw: int # -2..+2 (pre-weight signal strength) weight: int + group: str # "trend" | "momentum" | "volatility" @property def contribution(self) -> int: return self.raw * self.weight + @property + def max_contribution(self) -> int: + return 2 * self.weight + def _trend_score(fast: float, slow: float) -> tuple[int, str]: - """Score EMA fast vs slow. ±2 if separation >0.1%, ±1 if just above/below.""" if slow == 0: - return 0, "flat" + return 0, "no clear direction" sep = (fast - slow) / slow if sep >= 0.001: - return 2, "strong bullish" + return 2, "strongly rising" if sep > 0: - return 1, "bullish" + return 1, "gently rising" if sep <= -0.001: - return -2, "strong bearish" + return -2, "strongly falling" if sep < 0: - return -1, "bearish" - return 0, "flat" + return -1, "gently falling" + return 0, "moving sideways" def _macd_score(m: dict[str, float]) -> tuple[int, str]: - above = m["macd"] > m["signal"] + above = m["macd"] > m["signal"] rising = m["hist"] > m["hist_prev"] if above and m["hist"] > 0: - return (2, "bullish + rising") if rising else (1, "bullish") + return (2, "momentum building up (++ )") if rising else (1, "momentum leaning up (+ )") if not above and m["hist"] < 0: - return (-2, "bearish + falling") if not rising else (-1, "bearish") - return 0, "neutral" + return (-2, "momentum falling fast (-- )") if not rising else (-1, "momentum leaning down (- )") + return 0, "no strong momentum either way" def _rsi_score(value: float) -> tuple[int, str]: if value >= 80: - return -2, "extreme overbought" + return -2, "price may have risen too fast — likely to pull back" if value >= 70: - return -1, "overbought" + return -1, "price is running hot — watch for a dip" if value <= 20: - return 2, "extreme oversold" + return 2, "price may have fallen too fast — likely to bounce" if value <= 30: - return 1, "oversold" - return 0, "neutral" + return 1, "price is very low — possible bounce ahead" + return 0, "price is in a comfortable middle zone" def _stoch_score(s: dict[str, float]) -> tuple[int, str]: k, d, kp, dp = s["k"], s["d"], s["k_prev"], s["d_prev"] cross_down = kp >= dp and k < d - cross_up = kp <= dp and k > d + cross_up = kp <= dp and k > d if k >= 80 and cross_down: - return -2, "overbought + bearish cross" + return -2, "stretched high and starting to turn down — sell signal" if k >= 80: - return -1, "overbought" + return -1, "stretched high — buyers may be running out of steam" if k <= 20 and cross_up: - return 2, "oversold + bullish cross" + return 2, "stretched low and starting to turn up — buy signal" if k <= 20: - return 1, "oversold" - return 0, "neutral" + return 1, "stretched low — sellers may be running out of steam" + return 0, "price is in a normal range" def _bb_score(b: dict[str, float]) -> tuple[int, str]: if b["price"] >= b["upper"]: - return -1, "above upper band" + return -1, "price is at the top edge — could reverse down" if b["price"] <= b["lower"]: - return 1, "below lower band" - return 0, "inside bands" + return 1, "price is at the bottom edge — could bounce up" + rng = b["upper"] - b["lower"] + pct_b = (b["price"] - b["lower"]) / rng if rng else 0.5 + pos = "closer to the top" if pct_b >= 0.5 else "closer to the bottom" + return 0, f"price is inside the normal range ({pos})" def _verdict(score: int) -> str: @@ -115,24 +137,96 @@ def _verdict(score: int) -> str: return "NEUTRAL" -def emoji(verdict: str) -> str: +def ascii_indicator(verdict: str) -> str: return { - "STRONG BUY": "🟢🟢", - "BUY": "🟢", - "NEUTRAL": "⚪", - "SELL": "🔴", - "STRONG SELL": "🔴🔴", - }.get(verdict, "⚪") + "STRONG BUY": "[++] ", + "BUY": "[+] ", + "NEUTRAL": "[=] ", + "SELL": "[-] ", + "STRONG SELL": "[--] ", + }.get(verdict, "[=] ") + + +def interpret(readings: list[Reading], score: int, adx_val: float, verdict: str) -> str: + """Produce a plain-English explanation of the verdict, written for a beginner.""" + trend_r = [r for r in readings if r.group == "trend"] + momentum_r = [r for r in readings if r.group == "momentum"] + + trend_bull = sum(1 for r in trend_r if r.raw > 0) + trend_bear = sum(1 for r in trend_r if r.raw < 0) + mom_bull = sum(1 for r in momentum_r if r.raw > 0) + mom_bear = sum(1 for r in momentum_r if r.raw < 0) + + if verdict == "NEUTRAL": + if trend_bull > 0 and mom_bear > 0: + return ( + "The price has been rising, but buying energy is fading. " + "This often means a short dip is coming before the rise continues. " + "It's safer to wait and watch for buying pressure to come back before trading." + ) + if trend_bear > 0 and mom_bull > 0: + return ( + "The price has been falling, but selling pressure is easing up. " + "This could be a brief recovery before falling again — not a true reversal yet. " + "Wait to see if the recovery holds before making any move." + ) + if adx_val < 20: + return ( + "The market is calm and moving sideways — there is no clear up or down trend right now. " + "This is generally a poor time to trade. Wait until the market picks a direction." + ) + return ( + "The indicators are giving conflicting signals — some say up, some say down. " + "When signals disagree, it is best to stay out and wait for clarity." + ) + + if verdict == "BUY": + if adx_val >= 25: + return ( + "Most indicators agree: the price is trending upward with good strength behind it. " + "Any small dips are likely short-lived. " + "This looks like a reasonable opportunity to consider buying." + ) + return ( + "The majority of indicators are pointing upward, but the trend is still building. " + "There is a bullish lean here — proceed with caution and watch for the trend to strengthen." + ) + + if verdict == "STRONG BUY": + return ( + "All indicators are strongly aligned upward. " + "The price is rising and momentum is backing it up. " + "This is one of the clearest buy signals the bot can produce — but always use a stop-loss." + ) + + if verdict == "SELL": + if adx_val >= 25: + return ( + "Most indicators agree: the price is trending downward with real force behind it. " + "Any small bounces are likely short-lived. " + "This looks like a reasonable opportunity to consider selling." + ) + return ( + "The majority of indicators are pointing downward, but the trend is still developing. " + "There is a bearish lean here — proceed with caution and watch for the trend to strengthen." + ) + + if verdict == "STRONG SELL": + return ( + "All indicators are strongly aligned downward. " + "The price is falling and momentum is backing it up. " + "This is one of the clearest sell signals the bot can produce — but always use a stop-loss." + ) + + return "" def analyze(df: pd.DataFrame, htf_df: pd.DataFrame | None = None) -> dict: - """Run the full signal engine over a primary DataFrame and optional higher-TF DataFrame. + """Run the full signal engine. - Returns a dict with: readings (list[Reading]), score, max_score, confidence, - verdict, adx, atr, price, and a notes list (e.g., 'weak trend, halved'). + Returns readings (grouped), score, confidence, verdict, plain-English + context, ADX, ATR, price, and timestamp of the latest candle. """ - notes: list[str] = [] - e = ema_pair(df, 20, 50) ltf_raw, ltf_label = _trend_score(e["fast"], e["slow"]) @@ -140,15 +234,14 @@ def analyze(df: pd.DataFrame, htf_df: pd.DataFrame | None = None) -> dict: eh = ema_pair(htf_df, 20, 50) htf_raw, htf_label = _trend_score(eh["fast"], eh["slow"]) else: - htf_raw, htf_label = 0, "n/a (no higher TF)" + htf_raw, htf_label = 0, "n/a" adx_value = adx(df) - if adx_value < 20: - notes.append(f"ADX={adx_value:.1f} <20 → weak trend, trend scores halved") - ltf_raw = ltf_raw // 2 # towards zero + adx_weak = adx_value < 20 + adx_strong = adx_value >= 25 + if adx_weak: + ltf_raw = ltf_raw // 2 htf_raw = htf_raw // 2 - elif adx_value >= 25: - notes.append(f"ADX={adx_value:.1f} >=25 → strong trend") m = macd(df) macd_raw, macd_label = _macd_score(m) @@ -162,28 +255,36 @@ def analyze(df: pd.DataFrame, htf_df: pd.DataFrame | None = None) -> dict: b = bollinger(df) bb_raw, bb_label = _bb_score(b) - readings = [ - Reading("HTF Trend (EMA20/50)", htf_label, htf_raw, W_HTF_TREND), - Reading("LTF Trend (EMA20/50)", ltf_label, ltf_raw, W_LTF_TREND), - Reading("MACD", macd_label, macd_raw, W_MACD), - Reading(f"RSI={rsi_value:.1f}", rsi_label, rsi_raw, W_RSI), - Reading(f"Stoch %K={s['k']:.1f}", stoch_label, stoch_raw, W_STOCH), - Reading("Bollinger", bb_label, bb_raw, W_BB), + readings: list[Reading] = [ + Reading("HTF EMA 20/50", htf_label, htf_raw, W_HTF_TREND, "trend"), + Reading("LTF EMA 20/50", ltf_label, ltf_raw, W_LTF_TREND, "trend"), + Reading("MACD (12/26/9)", macd_label, macd_raw, W_MACD, "momentum"), + Reading(f"RSI 14 = {rsi_value:.1f}", rsi_label, rsi_raw, W_RSI, "momentum"), + Reading(f"Stoch %K = {s['k']:.1f}", stoch_label, stoch_raw, W_STOCH, "momentum"), + Reading("Bollinger (20,2)", bb_label, bb_raw, W_BB, "volatility"), ] score = sum(r.contribution for r in readings) confidence = round(abs(score) / MAX_SCORE * 100) verdict = _verdict(score) - atr_value = atr(df) + + adx_label = ( + f"{adx_value:.1f} — strong directional move" if adx_strong + else f"{adx_value:.1f} — no clear trend (market drifting)" if adx_weak + else f"{adx_value:.1f} — mild trend forming" + ) return { - "readings": readings, - "score": score, - "max_score": MAX_SCORE, + "readings": readings, + "score": score, + "max_score": MAX_SCORE, "confidence": confidence, - "verdict": verdict, - "adx": adx_value, - "atr": atr_value, - "price": b["price"], - "notes": notes, + "verdict": verdict, + "context": interpret(readings, score, adx_value, verdict), + "adx": adx_value, + "adx_label": adx_label, + "adx_weak": adx_weak, + "atr": atr(df), + "price": b["price"], + "timestamp": df.index[-1], }