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.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,8 +65,11 @@ 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()
@@ -59,7 +81,8 @@ def generate(
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
@@ -67,31 +90,40 @@ def generate(
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()
+102 -48
View File
@@ -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 <PAIR> — latest close (e.g. /price EURUSD)\n"
"/rsi <PAIR> <TF> — RSI value\n"
"/macd <PAIR> <TF> — MACD line/signal/histogram\n"
"/bb <PAIR> <TF> — Bollinger Bands\n"
"/signal <PAIR> <TF> — multi-indicator weighted verdict with chart\n"
"/chart <PAIR> <TF> — candlestick chart with EMA/BB/RSI\n"
"/help — this message"
"/pairs - list supported pairs/timeframes\n"
"/price <PAIR> - latest close (e.g. /price EURUSD)\n"
"/rsi <PAIR> <TF> - RSI value\n"
"/macd <PAIR> <TF> - MACD line/signal/histogram\n"
"/bb <PAIR> <TF> - Bollinger Bands\n"
"/signal <PAIR> <TF> - multi-indicator weighted verdict with chart\n"
"/chart <PAIR> <TF> - 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)
sl_dist = 1.5 * report["atr"]
tp_dist = 2.5 * report["atr"]
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 = [
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"):
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 += [
"",
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)",
f"_ATR-based zones:_ SL `{sl}` | TP `{tp}`",
]
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 += [
"",
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)",
f"_ATR-based zones:_ SL `{sl}` | TP `{tp}`",
]
if report["notes"]:
lines += ["", *[f"_{n}_" for n in report["notes"]]]
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"),
]
+155 -54
View File
@@ -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
@@ -29,55 +29,74 @@ HTF_OF: dict[str, str] = {
"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"]
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]:
@@ -85,22 +104,25 @@ def _stoch_score(s: dict[str, float]) -> tuple[int, str]:
cross_down = 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,19 +255,24 @@ 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,
@@ -182,8 +280,11 @@ def analyze(df: pd.DataFrame, htf_df: pd.DataFrame | None = None) -> dict:
"max_score": MAX_SCORE,
"confidence": confidence,
"verdict": verdict,
"context": interpret(readings, score, adx_value, verdict),
"adx": adx_value,
"atr": atr_value,
"adx_label": adx_label,
"adx_weak": adx_weak,
"atr": atr(df),
"price": b["price"],
"notes": notes,
"timestamp": df.index[-1],
}