Refine signal formatting, simplify indicators, and fix chart layout
This commit is contained in:
+171
-70
@@ -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],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user