Refine signal formatting, simplify indicators, and fix chart layout

This commit is contained in:
Harwinder
2026-05-01 17:11:19 -04:00
parent de96af798c
commit 9f4a720e30
3 changed files with 337 additions and 149 deletions
+50 -17
View File
@@ -9,6 +9,7 @@ matplotlib.use("Agg") # non-interactive backend — must be set before other im
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import matplotlib.ticker as ticker import matplotlib.ticker as ticker
import mplfinance as mpf import mplfinance as mpf
import numpy as np
import pandas as pd 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) 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( def generate(
df: pd.DataFrame, df: pd.DataFrame,
pair: str, pair: str,
@@ -46,8 +65,11 @@ def generate(
"""Render a candlestick chart with EMA/BB overlays and RSI panel. """Render a candlestick chart with EMA/BB overlays and RSI panel.
Returns raw PNG bytes for direct use with Telegram reply_photo. 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"] close = df["close"]
ema20_full = close.ewm(span=20, adjust=False).mean() ema20_full = close.ewm(span=20, adjust=False).mean()
ema50_full = close.ewm(span=50, adjust=False).mean() ema50_full = close.ewm(span=50, adjust=False).mean()
@@ -59,7 +81,8 @@ def generate(
n = min(candles, len(df)) n = min(candles, len(df))
data = df.tail(n).copy() 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 ema20 = ema20_full.tail(n).values
ema50 = ema50_full.tail(n).values ema50 = ema50_full.tail(n).values
@@ -67,31 +90,40 @@ def generate(
bb_lower = bb_lower_full.tail(n).values bb_lower = bb_lower_full.tail(n).values
rsi_vals = rsi_full.tail(n).values rsi_vals = rsi_full.tail(n).values
# ── Determine padded price range BEFORE plotting ──
ylim = _price_ylim(data)
addplots = [ addplots = [
mpf.make_addplot(ema20, color="#00bfff", width=1.4), mpf.make_addplot(ema20, color="#00bfff", width=1.5),
mpf.make_addplot(ema50, color="#ffa500", width=1.4), mpf.make_addplot(ema50, color="#ffa500", width=1.5),
mpf.make_addplot(bb_upper, color="#888888", width=0.8, linestyle="--", alpha=0.7), mpf.make_addplot(bb_upper, color="#888888", width=0.9,
mpf.make_addplot(bb_lower, color="#888888", width=0.8, linestyle="--", alpha=0.7), linestyle="--", alpha=0.7),
mpf.make_addplot(rsi_vals, panel=1, color="#b388ff", width=1.2, ylabel="RSI"), 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 "" verdict_label = f" {verdict} {confidence}%" if verdict else ""
title = f"{pair} {tf}{verdict_label}" title = f"{pair} {tf}{verdict_label}"
# ── Plot — taller figure + tight_layout gives price panel full room ──
fig, axes = mpf.plot( fig, axes = mpf.plot(
data, data,
type="candle", type="candle",
style=_STYLE, style=_STYLE,
addplot=addplots, addplot=addplots,
volume=False, volume=False,
panel_ratios=(3, 1), panel_ratios=(4, 1), # give price panel even more vertical share
title=title, title=title,
returnfig=True, returnfig=True,
figsize=(11, 6.5), figsize=(12, 7), # taller figure so candles breathe
tight_layout=False, 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 = axes[2]
rsi_ax.axhline(70, color="#ff5252", linewidth=0.8, linestyle="--", alpha=0.8) 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(50, color="#777777", linewidth=0.5, linestyle="--", alpha=0.5)
@@ -99,11 +131,12 @@ def generate(
rsi_ax.set_ylim(0, 100) rsi_ax.set_ylim(0, 100)
rsi_ax.yaxis.set_major_locator(ticker.MultipleLocator(20)) 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 = axes[0]
price_ax.plot([], [], color="#00bfff", linewidth=1.4, label="EMA 20") price_ax.plot([], [], color="#00bfff", linewidth=1.5, label="EMA 20 (short-term trend)")
price_ax.plot([], [], color="#ffa500", linewidth=1.4, label="EMA 50") price_ax.plot([], [], color="#ffa500", linewidth=1.5, label="EMA 50 (long-term trend)")
price_ax.plot([], [], color="#888888", linewidth=0.8, linestyle="--", label="BB(20,2)") price_ax.plot([], [], color="#888888", linewidth=0.9, linestyle="--",
label="Bollinger Bands")
price_ax.legend( price_ax.legend(
loc="upper left", loc="upper left",
fontsize=7, fontsize=7,
@@ -112,10 +145,10 @@ def generate(
labelcolor="white", 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() 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) plt.close(fig)
buf.seek(0) buf.seek(0)
return buf.read() return buf.read()
+102 -48
View File
@@ -13,7 +13,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 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, ascii_indicator, to_pips
from twelvedata import fetch_ohlc from twelvedata import fetch_ohlc
logging.basicConfig( logging.basicConfig(
@@ -27,14 +27,14 @@ DISCLAIMER = "\n\n_Educational only. Not financial advice._"
WELCOME = ( WELCOME = (
"*Forex Indicator Bot*\n\n" "*Forex Indicator Bot*\n\n"
"Commands:\n" "Commands:\n"
"/pairs — list supported pairs/timeframes\n" "/pairs - list supported pairs/timeframes\n"
"/price <PAIR> — latest close (e.g. /price EURUSD)\n" "/price <PAIR> - latest close (e.g. /price EURUSD)\n"
"/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 chart\n" "/signal <PAIR> <TF> - multi-indicator weighted verdict with chart\n"
"/chart <PAIR> <TF> — candlestick chart with EMA/BB/RSI\n" "/chart <PAIR> <TF> - candlestick chart with EMA/BB/RSI\n"
"/help — this message" "/help - this message"
) + DISCLAIMER ) + DISCLAIMER
@@ -128,9 +128,57 @@ async def bb_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None:
await _reply(update, f"Error: {e}") await _reply(update, f"Error: {e}")
def _format_reading(r) -> str: def _fmt_reading(r) -> str:
sign = "+" if r.contribution > 0 else "" """Format one indicator row.
return f"• {r.name}: {r.value} ({sign}{r.contribution})"
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: 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) pair, tf = _parse_pair_tf(ctx.args)
df = await fetch_ohlc(pair, tf) df = await fetch_ohlc(pair, tf)
img = generate_chart(df, 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") await update.message.reply_photo(img, caption=caption, parse_mode="Markdown")
except Exception as e: except Exception as e:
logger.exception("chart_cmd failed") 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 htf_df = await fetch_ohlc(pair, htf) if htf and htf != tf else None
report = analyze(df, htf_df) report = analyze(df, htf_df)
sl_dist = 1.5 * report["atr"]
tp_dist = 2.5 * report["atr"]
price = report["price"] 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 = [ lines: list[str] = [
f"*Signal* {pair} {tf} (HTF: {htf or 'n/a'})", f"[SIGNAL] *{pair} {tf}* | HTF: {htf or 'n/a'}",
f"Price: `{price:.5f}` ATR(14): `{report['atr']:.5f}`", f"`{_fmt_price(pair, price)}` | "
f"ATR `{_fmt_price(pair, atr_val)}` ({pips} pips) | _{ts}_",
"", "",
"*Indicators:*", *_fmt_group(report["readings"], "trend",
*[_format_reading(r) for r in report["readings"]], f"*-- TREND* (ADX {report['adx_label']}"
+ (" (!) scores halved" if report["adx_weak"] else "") + ")"),
"", "",
f"Score: `{report['score']:+d} / ±{report['max_score']}`", *_fmt_group(report["readings"], "momentum", "*-- MOMENTUM*"),
f"*Verdict:* {emoji(report['verdict'])} *{report['verdict']}*" "",
f" (confidence {report['confidence']}%)", *_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"): 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 += [ lines += [
"", "",
f"_Suggested zones (ATR-based):_", f"_ATR-based zones:_ SL `{sl}` | TP `{tp}`",
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"): elif verdict in ("SELL", "STRONG SELL"):
sl = _fmt_price(pair, price + sl_dist)
tp = _fmt_price(pair, price - tp_dist)
lines += [ lines += [
"", "",
f"_Suggested zones (ATR-based):_", f"_ATR-based zones:_ SL `{sl}` | TP `{tp}`",
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["notes"]:
lines += ["", *[f"_{n}_" for n in report["notes"]]]
caption = "\n".join(lines) + DISCLAIMER caption = "\n".join(lines) + DISCLAIMER
img = generate_chart( img = generate_chart(df, pair, tf, verdict=verdict, confidence=report["confidence"])
df, pair, tf,
verdict=report["verdict"],
confidence=report["confidence"],
)
await update.message.reply_photo(img, caption=caption, parse_mode="Markdown") 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")
@@ -207,12 +261,12 @@ BOT_COMMANDS: list[BotCommand] = [
BotCommand("start", "Show welcome and command list"), BotCommand("start", "Show welcome and command list"),
BotCommand("help", "Show welcome and command list"), BotCommand("help", "Show welcome and command list"),
BotCommand("pairs", "List supported pairs and timeframes"), BotCommand("pairs", "List supported pairs and timeframes"),
BotCommand("price", "Latest close price — /price EURUSD"), BotCommand("price", "Latest close price - /price EURUSD"),
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 + chart — /signal EURUSD 1h"), BotCommand("signal", "Multi-indicator verdict + chart - /signal EURUSD 1h"),
BotCommand("chart", "Candlestick chart with EMA/BB/RSI — /chart EURUSD 1h"), BotCommand("chart", "Candlestick chart with EMA/BB/RSI - /chart EURUSD 1h"),
] ]
+155 -54
View File
@@ -1,7 +1,7 @@
"""Signal scoring engine. """Signal scoring engine.
Each indicator contributes a score in [-2..+2] (sell..buy) with a weight. 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. mapped to a verdict and confidence percentage.
""" """
from __future__ import annotations from __future__ import annotations
@@ -29,55 +29,74 @@ HTF_OF: dict[str, str] = {
"1day": "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 @dataclass
class Reading: class Reading:
name: str name: str
value: str # display string for the user value: str # human-readable label
raw: int # -2..+2 raw: int # -2..+2 (pre-weight signal strength)
weight: int weight: int
group: str # "trend" | "momentum" | "volatility"
@property @property
def contribution(self) -> int: def contribution(self) -> int:
return self.raw * self.weight 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]: 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: if slow == 0:
return 0, "flat" return 0, "no clear direction"
sep = (fast - slow) / slow sep = (fast - slow) / slow
if sep >= 0.001: if sep >= 0.001:
return 2, "strong bullish" return 2, "strongly rising"
if sep > 0: if sep > 0:
return 1, "bullish" return 1, "gently rising"
if sep <= -0.001: if sep <= -0.001:
return -2, "strong bearish" return -2, "strongly falling"
if sep < 0: if sep < 0:
return -1, "bearish" return -1, "gently falling"
return 0, "flat" return 0, "moving sideways"
def _macd_score(m: dict[str, float]) -> tuple[int, str]: 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"] rising = m["hist"] > m["hist_prev"]
if above and m["hist"] > 0: 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: if not above and m["hist"] < 0:
return (-2, "bearish + falling") if not rising else (-1, "bearish") return (-2, "momentum falling fast (-- )") if not rising else (-1, "momentum leaning down (- )")
return 0, "neutral" return 0, "no strong momentum either way"
def _rsi_score(value: float) -> tuple[int, str]: def _rsi_score(value: float) -> tuple[int, str]:
if value >= 80: if value >= 80:
return -2, "extreme overbought" return -2, "price may have risen too fast — likely to pull back"
if value >= 70: if value >= 70:
return -1, "overbought" return -1, "price is running hot — watch for a dip"
if value <= 20: if value <= 20:
return 2, "extreme oversold" return 2, "price may have fallen too fast — likely to bounce"
if value <= 30: if value <= 30:
return 1, "oversold" return 1, "price is very low — possible bounce ahead"
return 0, "neutral" return 0, "price is in a comfortable middle zone"
def _stoch_score(s: dict[str, float]) -> tuple[int, str]: def _stoch_score(s: dict[str, float]) -> tuple[int, str]:
@@ -85,22 +104,25 @@ def _stoch_score(s: dict[str, float]) -> tuple[int, str]:
cross_down = kp >= dp and k < d 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: 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: if k >= 80:
return -1, "overbought" return -1, "stretched high — buyers may be running out of steam"
if k <= 20 and cross_up: 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: if k <= 20:
return 1, "oversold" return 1, "stretched low — sellers may be running out of steam"
return 0, "neutral" return 0, "price is in a normal range"
def _bb_score(b: dict[str, float]) -> tuple[int, str]: def _bb_score(b: dict[str, float]) -> tuple[int, str]:
if b["price"] >= b["upper"]: 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"]: if b["price"] <= b["lower"]:
return 1, "below lower band" return 1, "price is at the bottom edge — could bounce up"
return 0, "inside bands" 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: def _verdict(score: int) -> str:
@@ -115,24 +137,96 @@ def _verdict(score: int) -> str:
return "NEUTRAL" return "NEUTRAL"
def emoji(verdict: str) -> str: def ascii_indicator(verdict: str) -> str:
return { return {
"STRONG BUY": "🟢🟢", "STRONG BUY": "[++] ",
"BUY": "🟢", "BUY": "[+] ",
"NEUTRAL": "⚪", "NEUTRAL": "[=] ",
"SELL": "🔴", "SELL": "[-] ",
"STRONG SELL": "🔴🔴", "STRONG SELL": "[--] ",
}.get(verdict, "⚪") }.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: 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, Returns readings (grouped), score, confidence, verdict, plain-English
verdict, adx, atr, price, and a notes list (e.g., 'weak trend, halved'). context, ADX, ATR, price, and timestamp of the latest candle.
""" """
notes: list[str] = []
e = ema_pair(df, 20, 50) e = ema_pair(df, 20, 50)
ltf_raw, ltf_label = _trend_score(e["fast"], e["slow"]) 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) eh = ema_pair(htf_df, 20, 50)
htf_raw, htf_label = _trend_score(eh["fast"], eh["slow"]) htf_raw, htf_label = _trend_score(eh["fast"], eh["slow"])
else: else:
htf_raw, htf_label = 0, "n/a (no higher TF)" htf_raw, htf_label = 0, "n/a"
adx_value = adx(df) adx_value = adx(df)
if adx_value < 20: adx_weak = adx_value < 20
notes.append(f"ADX={adx_value:.1f} <20 → weak trend, trend scores halved") adx_strong = adx_value >= 25
ltf_raw = ltf_raw // 2 # towards zero if adx_weak:
ltf_raw = ltf_raw // 2
htf_raw = htf_raw // 2 htf_raw = htf_raw // 2
elif adx_value >= 25:
notes.append(f"ADX={adx_value:.1f} >=25 → strong trend")
m = macd(df) m = macd(df)
macd_raw, macd_label = _macd_score(m) macd_raw, macd_label = _macd_score(m)
@@ -162,19 +255,24 @@ def analyze(df: pd.DataFrame, htf_df: pd.DataFrame | None = None) -> dict:
b = bollinger(df) b = bollinger(df)
bb_raw, bb_label = _bb_score(b) bb_raw, bb_label = _bb_score(b)
readings = [ readings: list[Reading] = [
Reading("HTF Trend (EMA20/50)", htf_label, htf_raw, W_HTF_TREND), Reading("HTF EMA 20/50", htf_label, htf_raw, W_HTF_TREND, "trend"),
Reading("LTF Trend (EMA20/50)", ltf_label, ltf_raw, W_LTF_TREND), Reading("LTF EMA 20/50", ltf_label, ltf_raw, W_LTF_TREND, "trend"),
Reading("MACD", macd_label, macd_raw, W_MACD), Reading("MACD (12/26/9)", macd_label, macd_raw, W_MACD, "momentum"),
Reading(f"RSI={rsi_value:.1f}", rsi_label, rsi_raw, W_RSI), 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), Reading(f"Stoch %K = {s['k']:.1f}", stoch_label, stoch_raw, W_STOCH, "momentum"),
Reading("Bollinger", bb_label, bb_raw, W_BB), Reading("Bollinger (20,2)", bb_label, bb_raw, W_BB, "volatility"),
] ]
score = sum(r.contribution for r in readings) score = sum(r.contribution for r in readings)
confidence = round(abs(score) / MAX_SCORE * 100) confidence = round(abs(score) / MAX_SCORE * 100)
verdict = _verdict(score) 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 { return {
"readings": readings, "readings": readings,
@@ -182,8 +280,11 @@ def analyze(df: pd.DataFrame, htf_df: pd.DataFrame | None = None) -> dict:
"max_score": MAX_SCORE, "max_score": MAX_SCORE,
"confidence": confidence, "confidence": confidence,
"verdict": verdict, "verdict": verdict,
"context": interpret(readings, score, adx_value, verdict),
"adx": adx_value, "adx": adx_value,
"atr": atr_value, "adx_label": adx_label,
"adx_weak": adx_weak,
"atr": atr(df),
"price": b["price"], "price": b["price"],
"notes": notes, "timestamp": df.index[-1],
} }