Refine signal formatting, simplify indicators, and fix chart layout
This commit is contained in:
@@ -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,52 +65,65 @@ 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()
|
||||||
mid_full = close.rolling(20).mean()
|
mid_full = close.rolling(20).mean()
|
||||||
std_full = close.rolling(20).std(ddof=0)
|
std_full = close.rolling(20).std(ddof=0)
|
||||||
bb_upper_full = mid_full + 2.0 * std_full
|
bb_upper_full = mid_full + 2.0 * std_full
|
||||||
bb_lower_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 = 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
|
||||||
bb_upper = bb_upper_full.tail(n).values
|
bb_upper = bb_upper_full.tail(n).values
|
||||||
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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
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"]
|
lines: list[str] = [
|
||||||
tp_dist = 2.5 * report["atr"]
|
f"[SIGNAL] *{pair} {tf}* | HTF: {htf or 'n/a'}",
|
||||||
price = report["price"]
|
f"`{_fmt_price(pair, price)}` | "
|
||||||
|
f"ATR `{_fmt_price(pair, atr_val)}` ({pips} pips) | _{ts}_",
|
||||||
lines = [
|
|
||||||
f"*Signal* {pair} {tf} (HTF: {htf or 'n/a'})",
|
|
||||||
f"Price: `{price:.5f}` ATR(14): `{report['atr']:.5f}`",
|
|
||||||
"",
|
"",
|
||||||
"*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 += [
|
lines += ["", f"> _{report['context']}_"]
|
||||||
"",
|
|
||||||
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["notes"]:
|
sl_dist = 1.5 * atr_val
|
||||||
lines += ["", *[f"_{n}_" for n in report["notes"]]]
|
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
|
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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+171
-70
@@ -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
|
||||||
@@ -15,92 +15,114 @@ from indicators import adx, atr, bollinger, ema_pair, macd, rsi, stochastic
|
|||||||
# Weights — tuned so HTF trend dominates and momentum confirms.
|
# Weights — tuned so HTF trend dominates and momentum confirms.
|
||||||
W_HTF_TREND = 3
|
W_HTF_TREND = 3
|
||||||
W_LTF_TREND = 2
|
W_LTF_TREND = 2
|
||||||
W_MACD = 2
|
W_MACD = 2
|
||||||
W_RSI = 1
|
W_RSI = 1
|
||||||
W_STOCH = 1
|
W_STOCH = 1
|
||||||
W_BB = 1
|
W_BB = 1
|
||||||
MAX_SCORE = 2 * (W_HTF_TREND + W_LTF_TREND + W_MACD + W_RSI + W_STOCH + W_BB) # 20
|
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.
|
# Higher-timeframe mapping for the trend filter.
|
||||||
HTF_OF: dict[str, str] = {
|
HTF_OF: dict[str, str] = {
|
||||||
"15min": "1h",
|
"15min": "1h",
|
||||||
"1h": "4h",
|
"1h": "4h",
|
||||||
"4h": "1day",
|
"4h": "1day",
|
||||||
"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]:
|
||||||
k, d, kp, dp = s["k"], s["d"], s["k_prev"], s["d_prev"]
|
k, d, kp, dp = s["k"], s["d"], s["k_prev"], s["d_prev"]
|
||||||
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,28 +255,36 @@ 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,
|
||||||
"score": score,
|
"score": score,
|
||||||
"max_score": MAX_SCORE,
|
"max_score": MAX_SCORE,
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
"verdict": verdict,
|
"verdict": verdict,
|
||||||
"adx": adx_value,
|
"context": interpret(readings, score, adx_value, verdict),
|
||||||
"atr": atr_value,
|
"adx": adx_value,
|
||||||
"price": b["price"],
|
"adx_label": adx_label,
|
||||||
"notes": notes,
|
"adx_weak": adx_weak,
|
||||||
|
"atr": atr(df),
|
||||||
|
"price": b["price"],
|
||||||
|
"timestamp": df.index[-1],
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user