fc55da17db
New additive columns on every logged scan: bar_ts (primary closed-bar label, indexed with timeframe), engine_fingerprint (hash of the scoring config incl. the regime weight table in force - SignalAggregator.engine_config_fingerprint, twin of the gate fingerprint), code_version (short git SHA), penalty_tags_json / penalty_total / momentum_fading / exhaustion_count, and post-hoc watch_level / watch_kinds / watch_line written by violent_watch.attach_watch via signal_log.update_watch. Each indicators_json entry now also carries eff_weight (base weight x regime multiplier, the weight the composite actually used) and wscore. engine: IndicatorSignal.effective_weight / weighted_score are stamped inside _compute_composite (record-keeping only); regime_weight_table(config) is the single source of truth for the multiplier table (override honoured). Composite output byte-identical (120-report hash proof). New table scan_outcomes: model-free forward moves per scan (fwd_1h/4h/24h in $ and ATRs, mfe/mae over 24h, burst_1h, bars_used, complete) measured on the stored candle stream with the advisory model's continuity rule; labelled opportunistically at the end of log() (time-boxed, throttled, idempotent) - no daemon; scripts/label_scan_outcomes.py drives the backlog (--dry-run is read-only). analyze_signal_log --scan-outcomes: forward move + hit rate by TF x signal/grade with a NEUTRAL baseline and burst rate by watch level. Readers dedup by the first row per (timeframe, bar_ts). Module 31 (10 tests). Suite: 463 passed. Backtester untouched (test-locked). Claude-Session: https://claude.ai/code/session_01RWV2yLt3YXHHGVYHSQU2EE
1203 lines
50 KiB
Python
Executable File
1203 lines
50 KiB
Python
Executable File
"""
|
|
Forex Signal Intelligence Platform - Technical Analysis Engine
|
|
===============================================================
|
|
Computes indicators, detects patterns, and generates technical signals.
|
|
Each indicator returns both a value and a signal interpretation.
|
|
|
|
Design philosophy:
|
|
- No single indicator is trusted alone
|
|
- Confluence of multiple signals = higher confidence
|
|
- Divergences are weighted heavily (they predict reversals)
|
|
- Market regime (trending vs ranging) changes which signals matter
|
|
"""
|
|
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from config import TechnicalConfig, SignalStrength
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class IndicatorSignal:
|
|
"""Individual indicator's signal output."""
|
|
name: str
|
|
value: float
|
|
signal: SignalStrength
|
|
confidence: float # 0-100
|
|
description: str
|
|
weight: float = 1.0
|
|
# RECORD-KEEPING ONLY (stamped by _compute_composite; nothing reads
|
|
# them back). effective_weight = weight x regime multiplier actually
|
|
# used, weighted_score = its contribution to the composite. Both stay
|
|
# None for indicators the composite skipped (weight 0, `_`-prefixed
|
|
# private series) and for analyses that never reached _compute_composite.
|
|
effective_weight: Optional[float] = None
|
|
weighted_score: Optional[float] = None
|
|
|
|
|
|
@dataclass
|
|
class TechnicalAnalysis:
|
|
"""Complete technical analysis result for a pair/timeframe."""
|
|
symbol: str
|
|
timeframe: str
|
|
timestamp: str
|
|
indicators: Dict[str, IndicatorSignal] = field(default_factory=dict)
|
|
composite_signal: SignalStrength = SignalStrength.NEUTRAL
|
|
composite_score: float = 0.0 # -100 to +100
|
|
confidence: float = 0.0 # 0 to 100
|
|
market_regime: str = "unknown" # trending_up, trending_down, ranging
|
|
volatility_regime: str = "normal" # low, normal, high, extreme
|
|
key_levels: Dict[str, float] = field(default_factory=dict)
|
|
pattern_signals: List[str] = field(default_factory=list)
|
|
|
|
|
|
# NOTE: ROC is directionally redundant with MACD/Keltner/EMA
|
|
# (0.8-0.94 correlation), but zeroing its weight was backtested
|
|
# 2026-07 and LOST 13R — the redundant vote effectively raises
|
|
# the composite bar and the thresholds are calibrated with it.
|
|
# Don't remove without retuning score thresholds.
|
|
_TRENDING_WEIGHTS = {
|
|
"ema_trend": 2.0, "rsi": 1.0, "macd": 2.0,
|
|
"bollinger": 0.5, "keltner": 1.5, "roc": 1.5,
|
|
"candlestick": 0.8, "divergence": 1.0, "adx": 0.8,
|
|
}
|
|
_DEFAULT_REGIME_WEIGHTS = {
|
|
"trending_up": _TRENDING_WEIGHTS,
|
|
"trending_down": _TRENDING_WEIGHTS,
|
|
"trending": _TRENDING_WEIGHTS,
|
|
"ranging": {
|
|
"ema_trend": 0.3, "rsi": 1.5, "macd": 1.5,
|
|
"bollinger": 1.5, "keltner": 2.0, "roc": 0.5,
|
|
"candlestick": 1.0, "divergence": 2.5, "adx": 0.5,
|
|
},
|
|
"transitioning": {
|
|
"ema_trend": 1.5, "rsi": 1.0, "macd": 2.5,
|
|
"bollinger": 1.0, "keltner": 2.5, "roc": 2.0,
|
|
"candlestick": 1.0, "divergence": 2.0, "adx": 1.0,
|
|
},
|
|
}
|
|
|
|
|
|
def regime_weight_table(config: TechnicalConfig) -> Dict[str, Dict[str, float]]:
|
|
"""The regime -> indicator weight multipliers _compute_composite runs
|
|
with (``regime_weights_override`` wins, else the gold-optimized
|
|
defaults above).
|
|
|
|
Pure and side-effect free: ONE source of truth shared by the composite
|
|
and by signal_aggregator.engine_config_fingerprint. Returns a copy, so
|
|
a caller can never mutate the shipped table."""
|
|
override = getattr(config, "regime_weights_override", None)
|
|
table = override if override else _DEFAULT_REGIME_WEIGHTS
|
|
return {regime: dict(weights) for regime, weights in table.items()}
|
|
|
|
|
|
class TechnicalEngine:
|
|
"""
|
|
Core technical analysis engine.
|
|
|
|
Computes all indicators on a given OHLCV DataFrame and produces
|
|
a composite signal with confidence scoring.
|
|
"""
|
|
|
|
def __init__(self, config: TechnicalConfig):
|
|
self.config = config
|
|
|
|
def analyze(self, df: pd.DataFrame, symbol: str, timeframe: str) -> TechnicalAnalysis:
|
|
"""
|
|
Run full technical analysis on OHLCV data.
|
|
|
|
Returns TechnicalAnalysis with all indicators, composite signal,
|
|
and market regime classification.
|
|
"""
|
|
if df is None or len(df) < 30:
|
|
return TechnicalAnalysis(
|
|
symbol=symbol,
|
|
timeframe=timeframe,
|
|
timestamp=str(pd.Timestamp.utcnow()),
|
|
confidence=0.0,
|
|
)
|
|
|
|
result = TechnicalAnalysis(
|
|
symbol=symbol,
|
|
timeframe=timeframe,
|
|
timestamp=str(df.index[-1]),
|
|
)
|
|
|
|
# ── Compute all indicators ─────────────────────────────────
|
|
disabled = set(self.config.disabled_indicators or [])
|
|
|
|
# ATR first: other indicators (EMA ribbon, candlestick) use it
|
|
if "atr" not in disabled: self._compute_atr(df, result)
|
|
if "ema_trend" not in disabled: self._compute_ema_signals(df, result)
|
|
if "rsi" not in disabled: self._compute_rsi(df, result)
|
|
if "bollinger" not in disabled: self._compute_bollinger(df, result)
|
|
if "adx" not in disabled: self._compute_adx(df, result)
|
|
|
|
# MACD always runs — divergence needs the histogram — but the
|
|
# scored pillar must still honour disabled_indicators.
|
|
self._compute_macd(df, result)
|
|
if "macd" in disabled:
|
|
result.indicators.pop("macd", None)
|
|
|
|
if "roc" not in disabled: self._compute_roc(df, result)
|
|
if "keltner" not in disabled: self._compute_keltner(df, result)
|
|
|
|
if "divergence" not in disabled: self._detect_divergences(df, result)
|
|
if "structure" not in disabled: self._compute_structure_sr(df, result)
|
|
if "candlestick" not in disabled: self._detect_candlestick_patterns(df, result)
|
|
|
|
# ── Classify market regime ─────────────────────────────────
|
|
self._classify_regime(df, result)
|
|
|
|
# ── Regime-aware RSI reinterpretation ──────────────────────
|
|
if self.config.rsi_mode == "regime_aware" and "rsi" not in disabled:
|
|
self._reinterpret_rsi_for_regime(result)
|
|
|
|
# ── Compute composite signal ───────────────────────────────
|
|
self._compute_composite(result)
|
|
|
|
return result
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# INDICATOR COMPUTATIONS
|
|
# ══════════════════════════════════════════════════════════════
|
|
|
|
def _compute_ema_signals(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""EMA trend alignment — supports 4-EMA and 2-EMA modes."""
|
|
if self.config.ema_mode == "2ema":
|
|
self._compute_ema_2(df, result)
|
|
else:
|
|
self._compute_ema_4(df, result)
|
|
|
|
def _compute_ema_2(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""EMA 50/200 trend alignment — cleaner, less noise on H1."""
|
|
c = self.config
|
|
close = df["Close"]
|
|
|
|
ema50 = close.ewm(span=c.ema_slow, adjust=False).mean()
|
|
ema200 = close.ewm(span=c.ema_trend, adjust=False).mean() if len(close) >= c.ema_trend else None
|
|
ema21 = close.ewm(span=c.ema_medium, adjust=False).mean()
|
|
|
|
latest = close.iloc[-1]
|
|
s = ema50.iloc[-1]
|
|
m = ema21.iloc[-1]
|
|
t = ema200.iloc[-1] if ema200 is not None and len(ema200) >= c.ema_trend else None
|
|
|
|
if t is None:
|
|
result.indicators["ema_trend"] = IndicatorSignal(
|
|
name="EMA Trend", value=0, signal=SignalStrength.NEUTRAL,
|
|
confidence=30, description="EMA200 data insufficient", weight=1.0,
|
|
)
|
|
result.key_levels["ema_medium"] = float(m)
|
|
result.key_levels["ema_slow"] = float(s)
|
|
return
|
|
|
|
# 3-level alignment: price vs EMA50, EMA50 vs EMA200
|
|
if latest > s > t:
|
|
signal = SignalStrength.STRONG_BUY
|
|
conf = 85
|
|
desc = "Full bullish: price > EMA50 > EMA200"
|
|
elif latest > s and s < t:
|
|
signal = SignalStrength.BUY
|
|
conf = 60
|
|
desc = "Emerging uptrend: price > EMA50, EMA50 < EMA200"
|
|
elif latest < s < t:
|
|
signal = SignalStrength.STRONG_SELL
|
|
conf = 85
|
|
desc = "Full bearish: price < EMA50 < EMA200"
|
|
elif latest < s and s > t:
|
|
signal = SignalStrength.SELL
|
|
conf = 60
|
|
desc = "Emerging downtrend: price < EMA50, EMA50 > EMA200"
|
|
else:
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 40
|
|
desc = "EMA alignment mixed"
|
|
|
|
# EMA50/200 crossover detection (recent 10 bars)
|
|
if len(ema50) >= 10 and ema200 is not None:
|
|
for lookback in range(2, min(11, len(ema50))):
|
|
prev_s = ema50.iloc[-lookback]
|
|
prev_t = ema200.iloc[-lookback]
|
|
if prev_s <= prev_t and s > t:
|
|
desc += " | Golden cross (50/200)"
|
|
conf = min(conf + 10, 95)
|
|
break
|
|
elif prev_s >= prev_t and s < t:
|
|
desc += " | Death cross (50/200)"
|
|
conf = min(conf + 10, 95)
|
|
break
|
|
|
|
normalized = 1.0 if signal == SignalStrength.STRONG_BUY else (
|
|
0.5 if signal == SignalStrength.BUY else (
|
|
-1.0 if signal == SignalStrength.STRONG_SELL else (
|
|
-0.5 if signal == SignalStrength.SELL else 0.0)))
|
|
|
|
result.indicators["ema_trend"] = IndicatorSignal(
|
|
name="EMA Trend", value=normalized,
|
|
signal=signal, confidence=conf,
|
|
description=desc, weight=1.0,
|
|
)
|
|
result.key_levels["ema_trend"] = float(t)
|
|
result.key_levels["ema_medium"] = float(m)
|
|
result.key_levels["ema_slow"] = float(s)
|
|
|
|
def _compute_ema_4(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""Original 4-EMA (9/21/50/200) trend alignment analysis."""
|
|
c = self.config
|
|
close = df["Close"]
|
|
|
|
ema_fast = close.ewm(span=c.ema_fast, adjust=False).mean()
|
|
ema_medium = close.ewm(span=c.ema_medium, adjust=False).mean()
|
|
ema_slow = close.ewm(span=c.ema_slow, adjust=False).mean()
|
|
ema_trend = close.ewm(span=c.ema_trend, adjust=False).mean() if len(close) >= c.ema_trend else None
|
|
|
|
latest = close.iloc[-1]
|
|
f, m, s = ema_fast.iloc[-1], ema_medium.iloc[-1], ema_slow.iloc[-1]
|
|
t = ema_trend.iloc[-1] if ema_trend is not None and len(ema_trend) >= c.ema_trend else None
|
|
|
|
alignment_score = 0
|
|
checks = [(latest, f), (f, m), (m, s)]
|
|
if t is not None:
|
|
checks.append((s, t))
|
|
|
|
for above, below in checks:
|
|
if above > below:
|
|
alignment_score += 1
|
|
elif above < below:
|
|
alignment_score -= 1
|
|
|
|
max_checks = len(checks)
|
|
normalized = alignment_score / max_checks if max_checks > 0 else 0
|
|
|
|
if normalized > 0.6:
|
|
signal = SignalStrength.STRONG_BUY
|
|
conf = min(90, 60 + normalized * 30)
|
|
elif normalized > 0.2:
|
|
signal = SignalStrength.BUY
|
|
conf = 55 + normalized * 20
|
|
elif normalized < -0.6:
|
|
signal = SignalStrength.STRONG_SELL
|
|
conf = min(90, 60 + abs(normalized) * 30)
|
|
elif normalized < -0.2:
|
|
signal = SignalStrength.SELL
|
|
conf = 55 + abs(normalized) * 20
|
|
else:
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 40
|
|
|
|
# Partial alignment (plain BUY/SELL, 2-3 of 4 checks) carries no
|
|
# edge on M5 — squash to NEUTRAL when the research knob is on.
|
|
if c.ema_partial_neutral and signal in (SignalStrength.BUY, SignalStrength.SELL):
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 40
|
|
|
|
crossover_desc = ""
|
|
if len(ema_fast) >= 3:
|
|
prev_f, prev_m = ema_fast.iloc[-2], ema_medium.iloc[-2]
|
|
if prev_f <= prev_m and f > m:
|
|
crossover_desc = " | Golden cross (fast/medium)"
|
|
conf = min(conf + 10, 95)
|
|
elif prev_f >= prev_m and f < m:
|
|
crossover_desc = " | Death cross (fast/medium)"
|
|
conf = min(conf + 10, 95)
|
|
|
|
# EMA ribbon compression/expansion detection
|
|
ribbon_desc = ""
|
|
atr_val = result.key_levels.get("atr", 0)
|
|
ema_values = [f, m, s]
|
|
if atr_val > 0:
|
|
ribbon_width = (max(ema_values) - min(ema_values)) / atr_val
|
|
if ribbon_width < 1.0:
|
|
ribbon_desc = " | Ribbon COMPRESSED (trend change imminent)"
|
|
if len(ema_fast) >= 6:
|
|
prev_vals = [ema_fast.iloc[-5], ema_medium.iloc[-5], ema_slow.iloc[-5]]
|
|
prev_width = (max(prev_vals) - min(prev_vals)) / atr_val if atr_val > 0 else 0
|
|
if prev_width < 1.0 and ribbon_width >= 1.0:
|
|
ribbon_desc = " | Ribbon EXPANDING (new trend starting)"
|
|
conf = min(conf + 10, 95)
|
|
|
|
desc = f"EMA alignment: {alignment_score}/{max_checks}{crossover_desc}{ribbon_desc}"
|
|
result.indicators["ema_trend"] = IndicatorSignal(
|
|
name="EMA Trend", value=normalized,
|
|
signal=signal, confidence=conf,
|
|
description=desc, weight=1.0,
|
|
)
|
|
if t is not None:
|
|
result.key_levels["ema_trend"] = float(t)
|
|
result.key_levels["ema_fast"] = float(f)
|
|
result.key_levels["ema_medium"] = float(m)
|
|
result.key_levels["ema_slow"] = float(s)
|
|
|
|
def _compute_rsi(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""RSI with trend-adjusted interpretation."""
|
|
c = self.config
|
|
close = df["Close"]
|
|
delta = close.diff()
|
|
|
|
gain = delta.where(delta > 0, 0.0)
|
|
loss = (-delta).where(delta < 0, 0.0)
|
|
|
|
avg_gain = gain.ewm(alpha=1/c.rsi_period, min_periods=c.rsi_period, adjust=False).mean()
|
|
avg_loss = loss.ewm(alpha=1/c.rsi_period, min_periods=c.rsi_period, adjust=False).mean()
|
|
|
|
rs = avg_gain / avg_loss.replace(0, 1e-10)
|
|
rsi = 100 - (100 / (1 + rs))
|
|
rsi_val = rsi.iloc[-1] if not rsi.empty else 50.0
|
|
|
|
if pd.isna(rsi_val):
|
|
rsi_val = 50.0
|
|
|
|
# Signal interpretation (context-aware)
|
|
if rsi_val >= c.rsi_overbought:
|
|
signal = SignalStrength.SELL
|
|
conf = min(80, 50 + (rsi_val - c.rsi_overbought))
|
|
desc = f"RSI {rsi_val:.1f} — Overbought"
|
|
elif rsi_val <= c.rsi_oversold:
|
|
signal = SignalStrength.BUY
|
|
conf = min(80, 50 + (c.rsi_oversold - rsi_val))
|
|
desc = f"RSI {rsi_val:.1f} — Oversold"
|
|
elif rsi_val >= c.rsi_neutral_upper:
|
|
signal = SignalStrength.BUY # bullish momentum
|
|
conf = 45
|
|
desc = f"RSI {rsi_val:.1f} — Bullish momentum zone"
|
|
elif rsi_val <= c.rsi_neutral_lower:
|
|
signal = SignalStrength.SELL
|
|
conf = 45
|
|
desc = f"RSI {rsi_val:.1f} — Bearish momentum zone"
|
|
else:
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 30
|
|
desc = f"RSI {rsi_val:.1f} — Neutral"
|
|
|
|
result.indicators["rsi"] = IndicatorSignal(
|
|
name="RSI", value=rsi_val, signal=signal,
|
|
confidence=conf, description=desc, weight=1.5,
|
|
)
|
|
|
|
# Attach RSI series for divergence detection (dynamic attribute, filtered by _prefix)
|
|
result._rsi_series = rsi # type: ignore[attr-defined]
|
|
|
|
def _compute_macd(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""MACD computation — always runs for divergence; optionally a signal pillar."""
|
|
c = self.config
|
|
close = df["Close"]
|
|
|
|
ema_fast = close.ewm(span=c.macd_fast, adjust=False).mean()
|
|
ema_slow = close.ewm(span=c.macd_slow, adjust=False).mean()
|
|
macd_line = ema_fast - ema_slow
|
|
signal_line = macd_line.ewm(span=c.macd_signal, adjust=False).mean()
|
|
histogram = macd_line - signal_line
|
|
|
|
result._macd_histogram = histogram # type: ignore[attr-defined]
|
|
|
|
if not c.macd_as_pillar:
|
|
return
|
|
|
|
if len(histogram) < 3:
|
|
return
|
|
|
|
hist_now = histogram.iloc[-1]
|
|
hist_prev = histogram.iloc[-2]
|
|
hist_prev2 = histogram.iloc[-3]
|
|
|
|
if pd.isna(hist_now) or pd.isna(hist_prev):
|
|
return
|
|
|
|
# Histogram zero-line crossover
|
|
crossed_up = hist_prev <= 0 < hist_now
|
|
crossed_down = hist_prev >= 0 > hist_now
|
|
# Momentum acceleration
|
|
accel_up = hist_now > 0 and hist_now > hist_prev > 0
|
|
accel_down = hist_now < 0 and hist_now < hist_prev < 0
|
|
# Momentum fading
|
|
fading_up = hist_now > 0 and hist_now < hist_prev
|
|
fading_down = hist_now < 0 and hist_now > hist_prev
|
|
|
|
# Normalize histogram relative to recent range for confidence
|
|
hist_abs = histogram.abs()
|
|
if len(hist_abs) >= 20:
|
|
hist_pct = (abs(hist_now) / hist_abs.iloc[-20:].mean()) if hist_abs.iloc[-20:].mean() > 0 else 1.0
|
|
else:
|
|
hist_pct = 1.0
|
|
|
|
if crossed_up or accel_up:
|
|
signal = SignalStrength.STRONG_BUY if accel_up else SignalStrength.BUY
|
|
conf = min(80, 55 + hist_pct * 10)
|
|
desc = "MACD histogram" + (" accelerating ↑" if accel_up else " crossed above zero")
|
|
elif crossed_down or accel_down:
|
|
signal = SignalStrength.STRONG_SELL if accel_down else SignalStrength.SELL
|
|
conf = min(80, 55 + hist_pct * 10)
|
|
desc = "MACD histogram" + (" accelerating ↓" if accel_down else " crossed below zero")
|
|
elif fading_up:
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 35
|
|
desc = f"MACD histogram positive but fading"
|
|
elif fading_down:
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 35
|
|
desc = f"MACD histogram negative but fading"
|
|
else:
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 30
|
|
desc = f"MACD histogram flat"
|
|
|
|
result.indicators["macd"] = IndicatorSignal(
|
|
name="MACD", value=float(hist_now),
|
|
signal=signal, confidence=conf,
|
|
description=desc, weight=1.5,
|
|
)
|
|
|
|
def _compute_bollinger(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""Bollinger Bands position and squeeze detection."""
|
|
c = self.config
|
|
close = df["Close"]
|
|
|
|
sma = close.rolling(c.bb_period).mean()
|
|
std = close.rolling(c.bb_period).std(ddof=0)
|
|
upper = sma + c.bb_std * std
|
|
lower = sma - c.bb_std * std
|
|
|
|
latest = close.iloc[-1]
|
|
upper_val = upper.iloc[-1]
|
|
lower_val = lower.iloc[-1]
|
|
sma_val = sma.iloc[-1]
|
|
|
|
# Guard against NaN (insufficient data)
|
|
if pd.isna(upper_val) or pd.isna(lower_val) or pd.isna(sma_val) or sma_val == 0:
|
|
return
|
|
|
|
band_width = (upper_val - lower_val) / sma_val
|
|
|
|
# Position within bands (0 = lower band, 1 = upper band)
|
|
bb_range = upper_val - lower_val
|
|
bb_position = (latest - lower_val) / bb_range if bb_range != 0 else 0.5
|
|
|
|
# Squeeze detection (band width compared to recent average)
|
|
is_squeeze = False
|
|
if len(std) >= 20:
|
|
avg_width_series = ((upper - lower) / sma).rolling(20).mean()
|
|
avg_width = avg_width_series.iloc[-1]
|
|
if not pd.isna(avg_width) and avg_width > 0:
|
|
is_squeeze = band_width < avg_width * 0.75
|
|
|
|
# Signal
|
|
squeeze_note = " | SQUEEZE detected (expect breakout)" if is_squeeze else ""
|
|
if bb_position > 1.0:
|
|
signal = SignalStrength.SELL
|
|
conf = 65
|
|
desc = f"Price above upper BB ({bb_position:.2f}){squeeze_note}"
|
|
elif bb_position < 0.0:
|
|
signal = SignalStrength.BUY
|
|
conf = 65
|
|
desc = f"Price below lower BB ({bb_position:.2f}){squeeze_note}"
|
|
elif bb_position > 0.8:
|
|
signal = SignalStrength.SELL
|
|
conf = 45
|
|
desc = f"Price near upper BB ({bb_position:.2f}){squeeze_note}"
|
|
elif bb_position < 0.2:
|
|
signal = SignalStrength.BUY
|
|
conf = 45
|
|
desc = f"Price near lower BB ({bb_position:.2f}){squeeze_note}"
|
|
else:
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 30
|
|
desc = f"Price mid-band ({bb_position:.2f}){squeeze_note}"
|
|
|
|
if is_squeeze:
|
|
conf = min(conf + 10, 85)
|
|
|
|
result.indicators["bollinger"] = IndicatorSignal(
|
|
name="Bollinger Bands", value=bb_position, signal=signal,
|
|
confidence=conf, description=desc, weight=1.5,
|
|
)
|
|
|
|
result.key_levels["bb_upper"] = upper_val
|
|
result.key_levels["bb_lower"] = lower_val
|
|
result.key_levels["bb_mid"] = sma_val
|
|
|
|
def _compute_adx(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""ADX trend strength measurement."""
|
|
c = self.config
|
|
high = df["High"]
|
|
low = df["Low"]
|
|
close = df["Close"]
|
|
|
|
# True Range
|
|
tr1 = high - low
|
|
tr2 = (high - close.shift()).abs()
|
|
tr3 = (low - close.shift()).abs()
|
|
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
|
|
|
|
# Directional Movement
|
|
plus_dm_raw = high.diff()
|
|
minus_dm_raw = -low.diff()
|
|
|
|
# Compare raw vs raw: sequential filtering would zero +DM on a tie
|
|
# but keep -DM (compared against the already-zeroed +DM) — a
|
|
# systematic bearish nudge. Wilder zeroes both on ties.
|
|
plus_dm = plus_dm_raw.where((plus_dm_raw > minus_dm_raw) & (plus_dm_raw > 0), 0.0)
|
|
minus_dm = minus_dm_raw.where((minus_dm_raw > plus_dm_raw) & (minus_dm_raw > 0), 0.0)
|
|
|
|
atr = tr.ewm(alpha=1/c.adx_period, min_periods=c.adx_period, adjust=False).mean()
|
|
plus_di = 100 * (plus_dm.ewm(alpha=1/c.adx_period, min_periods=c.adx_period, adjust=False).mean() / atr)
|
|
minus_di = 100 * (minus_dm.ewm(alpha=1/c.adx_period, min_periods=c.adx_period, adjust=False).mean() / atr)
|
|
|
|
dx = 100 * (plus_di - minus_di).abs() / np.maximum(plus_di + minus_di, 1e-10)
|
|
adx = dx.ewm(alpha=1/c.adx_period, min_periods=c.adx_period, adjust=False).mean()
|
|
|
|
adx_val = adx.iloc[-1] if not adx.empty and not pd.isna(adx.iloc[-1]) else 0.0
|
|
plus_di_last = plus_di.iloc[-1] if not plus_di.empty else np.nan
|
|
minus_di_last = minus_di.iloc[-1] if not minus_di.empty else np.nan
|
|
|
|
# Guard NaN — treat as equal (no directional edge) if data is missing
|
|
plus_di_val = float(plus_di_last) if not pd.isna(plus_di_last) else 0.0
|
|
minus_di_val = float(minus_di_last) if not pd.isna(minus_di_last) else 0.0
|
|
|
|
# Signal (ADX tells strength, DI tells direction)
|
|
if adx_val >= c.adx_very_strong:
|
|
trend_strength = "Very strong"
|
|
conf = 80
|
|
elif adx_val >= c.adx_strong_trend:
|
|
trend_strength = "Strong"
|
|
conf = 65
|
|
else:
|
|
trend_strength = "Weak/Ranging"
|
|
conf = 40
|
|
|
|
if plus_di_val > minus_di_val:
|
|
signal = SignalStrength.BUY if adx_val >= c.adx_strong_trend else SignalStrength.NEUTRAL
|
|
direction = "bullish"
|
|
else:
|
|
signal = SignalStrength.SELL if adx_val >= c.adx_strong_trend else SignalStrength.NEUTRAL
|
|
direction = "bearish"
|
|
|
|
# ADX slope — trend acceleration detection
|
|
slope_lookback = self.config.adx_slope_lookback
|
|
adx_slope = 0.0
|
|
if len(adx) > slope_lookback:
|
|
prev_adx = adx.iloc[-slope_lookback - 1]
|
|
if not pd.isna(prev_adx):
|
|
adx_slope = adx_val - prev_adx
|
|
|
|
slope_desc = ""
|
|
emerging_threshold = self.config.adx_slope_emerging_threshold
|
|
if adx_val < self.config.adx_strong_trend and adx_slope > emerging_threshold:
|
|
slope_desc = " | TREND EMERGING (ADX rising fast)"
|
|
conf = min(conf + 15, 85)
|
|
elif adx_val >= self.config.adx_strong_trend and adx_slope > 0:
|
|
slope_desc = f" | Trend strengthening (slope +{adx_slope:.1f})"
|
|
conf = min(conf + 5, 85)
|
|
elif adx_val >= self.config.adx_strong_trend and adx_slope < -emerging_threshold:
|
|
slope_desc = f" | Trend weakening (slope {adx_slope:.1f})"
|
|
conf = max(conf - 10, 30)
|
|
|
|
desc = f"ADX {adx_val:.1f} ({trend_strength} {direction}) | +DI={plus_di_val:.1f} -DI={minus_di_val:.1f}{slope_desc}"
|
|
|
|
result.indicators["adx"] = IndicatorSignal(
|
|
name="ADX", value=adx_val, signal=signal,
|
|
confidence=conf, description=desc, weight=0.5,
|
|
)
|
|
result._adx_slope = adx_slope # type: ignore[attr-defined]
|
|
|
|
def _compute_atr(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""ATR volatility measurement and regime classification."""
|
|
c = self.config
|
|
high, low, close = df["High"], df["Low"], df["Close"]
|
|
|
|
tr1 = high - low
|
|
tr2 = (high - close.shift()).abs()
|
|
tr3 = (low - close.shift()).abs()
|
|
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
|
|
|
|
atr = tr.ewm(alpha=1/c.atr_period, min_periods=c.atr_period, adjust=False).mean()
|
|
atr_val = atr.iloc[-1] if not atr.empty else 0.0
|
|
if pd.isna(atr_val):
|
|
atr_val = 0.0
|
|
|
|
# ATR as percentage of price
|
|
atr_pct = (atr_val / close.iloc[-1]) * 100 if close.iloc[-1] != 0 else 0
|
|
|
|
# Compare to historical ATR
|
|
if len(atr) >= 50:
|
|
atr_percentile = (atr.iloc[-1] > atr.iloc[-50:]).mean() * 100
|
|
else:
|
|
atr_percentile = 50.0
|
|
|
|
if atr_percentile > 80:
|
|
vol_regime = "extreme"
|
|
elif atr_percentile > 60:
|
|
vol_regime = "high"
|
|
elif atr_percentile > 30:
|
|
vol_regime = "normal"
|
|
else:
|
|
vol_regime = "low"
|
|
|
|
result.volatility_regime = vol_regime
|
|
result.key_levels["atr"] = atr_val
|
|
result.key_levels["atr_pct"] = atr_pct
|
|
|
|
result.indicators["atr"] = IndicatorSignal(
|
|
name="ATR", value=atr_val, signal=SignalStrength.NEUTRAL,
|
|
confidence=50,
|
|
description=f"ATR {atr_val:.5f} ({atr_pct:.3f}%) | Volatility: {vol_regime} (P{atr_percentile:.0f})",
|
|
weight=0.0, # ATR is context, not a directional signal
|
|
)
|
|
|
|
def _compute_roc(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""Rate of Change — momentum acceleration indicator."""
|
|
c = self.config
|
|
close = df["Close"]
|
|
n = c.roc_period
|
|
|
|
if len(close) < n + 5:
|
|
return
|
|
|
|
denom1 = close.iloc[-n - 1]
|
|
denom2 = close.iloc[-n - 2]
|
|
if denom1 == 0 or denom2 == 0:
|
|
return
|
|
roc_val = ((close.iloc[-1] - denom1) / denom1) * 100
|
|
roc_prev = ((close.iloc[-2] - denom2) / denom2) * 100
|
|
|
|
if pd.isna(roc_val) or pd.isna(roc_prev):
|
|
return
|
|
|
|
roc_rising = roc_val > roc_prev
|
|
roc_abs = abs(roc_val)
|
|
threshold = c.roc_threshold
|
|
|
|
if roc_val > 0 and roc_rising:
|
|
if roc_abs > threshold * 2:
|
|
signal = SignalStrength.STRONG_BUY
|
|
conf = min(80, 60 + roc_abs * 5)
|
|
desc = f"ROC +{roc_val:.2f}% — Strong momentum acceleration"
|
|
else:
|
|
signal = SignalStrength.BUY
|
|
conf = min(70, 50 + roc_abs * 10)
|
|
desc = f"ROC +{roc_val:.2f}% — Rising momentum"
|
|
elif roc_val < 0 and not roc_rising:
|
|
if roc_abs > threshold * 2:
|
|
signal = SignalStrength.STRONG_SELL
|
|
conf = min(80, 60 + roc_abs * 5)
|
|
desc = f"ROC {roc_val:.2f}% — Strong bearish momentum"
|
|
else:
|
|
signal = SignalStrength.SELL
|
|
conf = min(70, 50 + roc_abs * 10)
|
|
desc = f"ROC {roc_val:.2f}% — Falling momentum"
|
|
else:
|
|
# Zero-crosses land in the branches above by definition (a
|
|
# cross up is positive and rising, a cross down negative and
|
|
# falling) — only flat/fading momentum reaches here.
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 30
|
|
desc = f"ROC {roc_val:+.2f}% — No clear momentum"
|
|
|
|
result.indicators["roc"] = IndicatorSignal(
|
|
name="ROC", value=roc_val, signal=signal,
|
|
confidence=conf, description=desc, weight=1.5,
|
|
)
|
|
|
|
def _compute_keltner(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""Keltner Channels + Bollinger-Keltner squeeze detection (TTM Squeeze)."""
|
|
c = self.config
|
|
close = df["Close"]
|
|
high, low = df["High"], df["Low"]
|
|
|
|
if len(close) < max(c.kc_period, c.kc_atr_period) + 5:
|
|
return
|
|
|
|
kc_mid = close.ewm(span=c.kc_period, adjust=False).mean()
|
|
|
|
tr1 = high - low
|
|
tr2 = (high - close.shift()).abs()
|
|
tr3 = (low - close.shift()).abs()
|
|
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
|
|
kc_atr = tr.ewm(alpha=1/c.kc_atr_period, min_periods=c.kc_atr_period, adjust=False).mean()
|
|
|
|
kc_upper = kc_mid + c.kc_multiplier * kc_atr
|
|
kc_lower = kc_mid - c.kc_multiplier * kc_atr
|
|
|
|
latest = close.iloc[-1]
|
|
kc_upper_val = kc_upper.iloc[-1]
|
|
kc_lower_val = kc_lower.iloc[-1]
|
|
kc_mid_val = kc_mid.iloc[-1]
|
|
|
|
if pd.isna(kc_upper_val) or pd.isna(kc_lower_val):
|
|
return
|
|
|
|
bb_upper = result.key_levels.get("bb_upper")
|
|
bb_lower = result.key_levels.get("bb_lower")
|
|
|
|
is_squeeze = False
|
|
squeeze_released = False
|
|
if bb_upper is not None and bb_lower is not None:
|
|
is_squeeze = bb_lower > kc_lower_val and bb_upper < kc_upper_val
|
|
|
|
if len(close) >= 3:
|
|
bb_sma = close.rolling(c.bb_period).mean()
|
|
bb_std = close.rolling(c.bb_period).std(ddof=0)
|
|
if len(bb_sma) >= 2 and not pd.isna(bb_sma.iloc[-2]):
|
|
prev_bb_upper = bb_sma.iloc[-2] + c.bb_std * bb_std.iloc[-2]
|
|
prev_bb_lower = bb_sma.iloc[-2] - c.bb_std * bb_std.iloc[-2]
|
|
prev_kc_upper = kc_upper.iloc[-2]
|
|
prev_kc_lower = kc_lower.iloc[-2]
|
|
was_squeeze = prev_bb_lower > prev_kc_lower and prev_bb_upper < prev_kc_upper
|
|
squeeze_released = was_squeeze and not is_squeeze
|
|
|
|
macd_hist = getattr(result, "_macd_histogram", None)
|
|
macd_direction = 0
|
|
if macd_hist is not None and len(macd_hist) >= 1 and not pd.isna(macd_hist.iloc[-1]):
|
|
macd_direction = 1 if macd_hist.iloc[-1] > 0 else -1
|
|
|
|
if squeeze_released:
|
|
if macd_direction > 0:
|
|
signal = SignalStrength.STRONG_BUY
|
|
desc = "Keltner SQUEEZE RELEASED — bullish breakout"
|
|
elif macd_direction < 0:
|
|
signal = SignalStrength.STRONG_SELL
|
|
desc = "Keltner SQUEEZE RELEASED — bearish breakout"
|
|
else:
|
|
signal = SignalStrength.NEUTRAL
|
|
desc = "Keltner squeeze released — direction unclear"
|
|
conf = 80
|
|
elif is_squeeze:
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 60
|
|
desc = "Keltner SQUEEZE active — breakout imminent"
|
|
elif latest > kc_upper_val:
|
|
signal = SignalStrength.BUY
|
|
conf = 65
|
|
desc = f"Price above Keltner upper — strong uptrend continuation"
|
|
elif latest < kc_lower_val:
|
|
signal = SignalStrength.SELL
|
|
conf = 65
|
|
desc = f"Price below Keltner lower — strong downtrend continuation"
|
|
else:
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 30
|
|
desc = "Price within Keltner Channels"
|
|
|
|
result.indicators["keltner"] = IndicatorSignal(
|
|
name="Keltner", value=float(latest - kc_mid_val),
|
|
signal=signal, confidence=conf,
|
|
description=desc, weight=1.5,
|
|
)
|
|
|
|
result.key_levels["kc_upper"] = float(kc_upper_val)
|
|
result.key_levels["kc_lower"] = float(kc_lower_val)
|
|
result.key_levels["kc_mid"] = float(kc_mid_val)
|
|
result._kc_squeeze = is_squeeze # type: ignore[attr-defined]
|
|
result._kc_squeeze_released = squeeze_released # type: ignore[attr-defined]
|
|
|
|
def _detect_divergences(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""
|
|
Detect RSI and MACD divergences (powerful reversal signals).
|
|
|
|
Bullish divergence: Price makes lower low, indicator makes higher low
|
|
Bearish divergence: Price makes higher high, indicator makes lower high
|
|
"""
|
|
c = self.config
|
|
close = df["Close"]
|
|
lookback = min(c.divergence_lookback, len(close) - 1)
|
|
|
|
if lookback < 5:
|
|
return
|
|
|
|
rsi_series = getattr(result, "_rsi_series", None)
|
|
macd_hist = getattr(result, "_macd_histogram", None)
|
|
|
|
divergences = []
|
|
|
|
for name, indicator_series in [("RSI", rsi_series), ("MACD", macd_hist)]:
|
|
if indicator_series is None or len(indicator_series) < lookback:
|
|
continue
|
|
|
|
price_window = close.iloc[-lookback:]
|
|
ind_window = indicator_series.iloc[-lookback:]
|
|
|
|
# A price swing and an indicator swing only form one divergence
|
|
# observation if they mark the SAME market turn — require the
|
|
# swing bars to line up within a small tolerance, otherwise
|
|
# unrelated wiggles get compared and the signal is noise.
|
|
def _aligned(price_pts, ind_pts, tol=2):
|
|
pairs = []
|
|
for p_idx, p_val in price_pts:
|
|
best = min(ind_pts, key=lambda ip: abs(ip[0] - p_idx), default=None)
|
|
if best is not None and abs(best[0] - p_idx) <= tol:
|
|
pairs.append(((p_idx, p_val), best))
|
|
return pairs
|
|
|
|
# Bullish divergence: lower price low but higher indicator low
|
|
price_lows = self._find_swing_points(price_window, mode="low")
|
|
ind_lows = self._find_swing_points(ind_window, mode="low")
|
|
low_pairs = _aligned(price_lows, ind_lows)
|
|
if len(low_pairs) >= 2:
|
|
(p1, i1), (p2, i2) = low_pairs[-2], low_pairs[-1]
|
|
if p2[1] < p1[1] and i2[1] > i1[1]:
|
|
divergences.append(f"Bullish {name} divergence (reversal warning)")
|
|
|
|
# Bearish divergence: higher price high but lower indicator high
|
|
price_highs = self._find_swing_points(price_window, mode="high")
|
|
ind_highs = self._find_swing_points(ind_window, mode="high")
|
|
high_pairs = _aligned(price_highs, ind_highs)
|
|
if len(high_pairs) >= 2:
|
|
(p1, i1), (p2, i2) = high_pairs[-2], high_pairs[-1]
|
|
if p2[1] > p1[1] and i2[1] < i1[1]:
|
|
divergences.append(f"Bearish {name} divergence (reversal warning)")
|
|
|
|
if divergences:
|
|
is_bullish = any("Bullish" in d for d in divergences)
|
|
is_bearish = any("Bearish" in d for d in divergences)
|
|
|
|
if is_bullish and not is_bearish:
|
|
signal = SignalStrength.BUY
|
|
elif is_bearish and not is_bullish:
|
|
signal = SignalStrength.SELL
|
|
else:
|
|
signal = SignalStrength.NEUTRAL
|
|
|
|
result.indicators["divergence"] = IndicatorSignal(
|
|
name="Divergence", value=len(divergences),
|
|
signal=signal, confidence=70,
|
|
description=" | ".join(divergences),
|
|
weight=1.5,
|
|
)
|
|
# Note: divergences are NOT added to pattern_signals to avoid
|
|
# duplicate reason collection (the indicator entry covers them).
|
|
|
|
def _compute_structure_sr(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""Detect swing high/low structure levels for S/R and trade setup."""
|
|
base_lookback = self.config.swing_sr_lookback
|
|
if result.timeframe in ("1min", "5min"):
|
|
base_lookback = max(base_lookback, 120)
|
|
lookback = min(base_lookback, len(df) - 1)
|
|
if lookback < 10:
|
|
return
|
|
|
|
price = df["Close"].iloc[-1]
|
|
high_window = df["High"].iloc[-lookback:]
|
|
low_window = df["Low"].iloc[-lookback:]
|
|
|
|
swing_highs = self._find_swing_points(high_window, mode="high", window=3)
|
|
swing_lows = self._find_swing_points(low_window, mode="low", window=3)
|
|
|
|
highs_above = sorted([v for _, v in swing_highs if v > price])
|
|
highs_below = sorted([v for _, v in swing_highs if v <= price], reverse=True)
|
|
lows_below = sorted([v for _, v in swing_lows if v < price], reverse=True)
|
|
lows_above = sorted([v for _, v in swing_lows if v >= price])
|
|
|
|
for i, val in enumerate(highs_above[:3], 1):
|
|
result.key_levels[f"swing_high_{i}"] = float(val)
|
|
for i, val in enumerate(lows_below[:3], 1):
|
|
result.key_levels[f"swing_low_{i}"] = float(val)
|
|
|
|
if not highs_above and highs_below:
|
|
result.key_levels["swing_high_1"] = float(highs_below[0])
|
|
if not lows_below and lows_above:
|
|
result.key_levels["swing_low_1"] = float(lows_above[0])
|
|
|
|
def _detect_candlestick_patterns(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""Detect significant candlestick patterns on recent bars."""
|
|
if len(df) < 5:
|
|
return
|
|
|
|
patterns = []
|
|
o, h, l, c = df["Open"].iloc[-1], df["High"].iloc[-1], df["Low"].iloc[-1], df["Close"].iloc[-1]
|
|
prev_o, prev_c = df["Open"].iloc[-2], df["Close"].iloc[-2]
|
|
body = abs(c - o)
|
|
upper_wick = h - max(o, c)
|
|
lower_wick = min(o, c) - l
|
|
full_range = h - l
|
|
|
|
if full_range == 0:
|
|
return
|
|
|
|
body_ratio = body / full_range
|
|
|
|
# Doji (indecision)
|
|
if body_ratio < 0.1:
|
|
patterns.append("Doji (indecision)")
|
|
|
|
# Hammer / Hanging Man
|
|
if lower_wick > 2 * body and upper_wick < body * 0.5 and body_ratio > 0.1:
|
|
if c > o:
|
|
patterns.append("Hammer (bullish reversal)")
|
|
else:
|
|
patterns.append("Hanging Man (bearish warning)")
|
|
|
|
# Shooting Star / Inverted Hammer
|
|
if upper_wick > 2 * body and lower_wick < body * 0.5 and body_ratio > 0.1:
|
|
if c < o:
|
|
patterns.append("Shooting Star (bearish reversal)")
|
|
else:
|
|
patterns.append("Inverted Hammer (bullish)")
|
|
|
|
# Engulfing
|
|
if len(df) >= 3:
|
|
if prev_c < prev_o and c > o and c > prev_o and o < prev_c:
|
|
patterns.append("Bullish Engulfing")
|
|
elif prev_c > prev_o and c < o and c < prev_o and o > prev_c:
|
|
patterns.append("Bearish Engulfing")
|
|
|
|
# Marubozu (strong conviction candle)
|
|
if body_ratio > 0.85:
|
|
if c > o:
|
|
patterns.append("Bullish Marubozu (strong buyers)")
|
|
else:
|
|
patterns.append("Bearish Marubozu (strong sellers)")
|
|
|
|
if patterns:
|
|
bullish = sum(1 for p in patterns if "bullish" in p.lower() or "Hammer" in p)
|
|
bearish = sum(1 for p in patterns if "bearish" in p.lower() or "Shooting" in p)
|
|
|
|
if bullish > bearish:
|
|
signal = SignalStrength.BUY
|
|
elif bearish > bullish:
|
|
signal = SignalStrength.SELL
|
|
else:
|
|
signal = SignalStrength.NEUTRAL
|
|
|
|
conf = 50
|
|
atr_val = result.key_levels.get("atr", 0)
|
|
latest = c
|
|
proximity = atr_val * 0.5 if atr_val > 0 else 0
|
|
|
|
if proximity > 0:
|
|
swing_lows = sorted(
|
|
[v for k, v in result.key_levels.items() if k.startswith("swing_low_")]
|
|
)
|
|
swing_highs = sorted(
|
|
[v for k, v in result.key_levels.items() if k.startswith("swing_high_")]
|
|
)
|
|
near_support = any(abs(latest - sl) <= proximity for sl in swing_lows)
|
|
near_resistance = any(abs(latest - sh) <= proximity for sh in swing_highs)
|
|
|
|
if bullish > bearish and near_support:
|
|
conf += 20
|
|
patterns.append("(at support)")
|
|
elif bearish > bullish and near_resistance:
|
|
conf += 20
|
|
patterns.append("(at resistance)")
|
|
|
|
result.indicators["candlestick"] = IndicatorSignal(
|
|
name="Candlestick", value=float(bullish - bearish),
|
|
signal=signal, confidence=conf,
|
|
description=" | ".join(patterns),
|
|
weight=1.0,
|
|
)
|
|
result.pattern_signals.extend(patterns)
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# MARKET REGIME & COMPOSITE SCORING
|
|
# ══════════════════════════════════════════════════════════════
|
|
|
|
def _reinterpret_rsi_for_regime(self, result: TechnicalAnalysis):
|
|
"""Reinterpret RSI signal based on market regime — called after regime classification."""
|
|
rsi_ind = result.indicators.get("rsi")
|
|
if not rsi_ind:
|
|
return
|
|
|
|
rsi_val = rsi_ind.value
|
|
regime = result.market_regime
|
|
|
|
if regime == "trending_up":
|
|
if 38 <= rsi_val <= 55:
|
|
signal = SignalStrength.STRONG_BUY
|
|
conf = 75
|
|
desc = f"RSI {rsi_val:.1f} — Pullback zone in uptrend (ideal entry)"
|
|
elif 30 <= rsi_val < 38:
|
|
signal = SignalStrength.BUY
|
|
conf = 60
|
|
desc = f"RSI {rsi_val:.1f} — Deep pullback in uptrend"
|
|
elif 55 < rsi_val <= 65:
|
|
signal = SignalStrength.BUY
|
|
conf = 50
|
|
desc = f"RSI {rsi_val:.1f} — Momentum ok in uptrend"
|
|
elif rsi_val > 65:
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 30
|
|
desc = f"RSI {rsi_val:.1f} — Extended in uptrend, wait for pullback"
|
|
elif rsi_val < 30:
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 25
|
|
desc = f"RSI {rsi_val:.1f} — Oversold in uptrend, possible reversal"
|
|
else:
|
|
return
|
|
elif regime == "trending_down":
|
|
if 45 <= rsi_val <= 62:
|
|
signal = SignalStrength.STRONG_SELL
|
|
conf = 75
|
|
desc = f"RSI {rsi_val:.1f} — Bounce zone in downtrend (ideal short)"
|
|
elif 62 < rsi_val <= 70:
|
|
signal = SignalStrength.SELL
|
|
conf = 60
|
|
desc = f"RSI {rsi_val:.1f} — Deep bounce in downtrend"
|
|
elif 35 <= rsi_val < 45:
|
|
signal = SignalStrength.SELL
|
|
conf = 50
|
|
desc = f"RSI {rsi_val:.1f} — Momentum ok in downtrend"
|
|
elif rsi_val < 35:
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 30
|
|
desc = f"RSI {rsi_val:.1f} — Extended in downtrend, wait for bounce"
|
|
elif rsi_val > 70:
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 25
|
|
desc = f"RSI {rsi_val:.1f} — Overbought in downtrend, possible reversal"
|
|
else:
|
|
return
|
|
elif regime in ("transitioning", "trending"):
|
|
# Trend being born (transitioning) or strong ADX with EMAs
|
|
# unaligned (mixed "trending") — in both, RSI can't tell
|
|
# direction. Treating mixed-"trending" as an uptrend here was
|
|
# a systematic bullish bias (23 BUY / 1 SELL, -6.8R on the
|
|
# 6yr M15 book). Only extremes carry information.
|
|
if rsi_val >= 70:
|
|
signal = SignalStrength.SELL
|
|
conf = 50
|
|
desc = f"RSI {rsi_val:.1f} — Overbought while trend forming"
|
|
elif rsi_val <= 30:
|
|
signal = SignalStrength.BUY
|
|
conf = 50
|
|
desc = f"RSI {rsi_val:.1f} — Oversold while trend forming"
|
|
else:
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 30
|
|
desc = f"RSI {rsi_val:.1f} — No RSI edge while trend forms"
|
|
else: # ranging
|
|
if rsi_val >= 70:
|
|
signal = SignalStrength.SELL
|
|
conf = 70
|
|
desc = f"RSI {rsi_val:.1f} — Overbought in range"
|
|
elif rsi_val <= 30:
|
|
signal = SignalStrength.BUY
|
|
conf = 70
|
|
desc = f"RSI {rsi_val:.1f} — Oversold in range"
|
|
elif rsi_val >= 60:
|
|
signal = SignalStrength.SELL
|
|
conf = 45
|
|
desc = f"RSI {rsi_val:.1f} — Upper range zone"
|
|
elif rsi_val <= 40:
|
|
signal = SignalStrength.BUY
|
|
conf = 45
|
|
desc = f"RSI {rsi_val:.1f} — Lower range zone"
|
|
else:
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 30
|
|
desc = f"RSI {rsi_val:.1f} — Mid-range, no edge"
|
|
|
|
result.indicators["rsi"] = IndicatorSignal(
|
|
name="RSI", value=rsi_val, signal=signal,
|
|
confidence=conf, description=desc, weight=rsi_ind.weight,
|
|
)
|
|
|
|
def _classify_regime(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""Classify the market regime (trending vs ranging vs transitioning)."""
|
|
adx_sig = result.indicators.get("adx")
|
|
ema_sig = result.indicators.get("ema_trend")
|
|
adx_slope = getattr(result, "_adx_slope", 0.0)
|
|
|
|
if adx_sig and adx_sig.value >= self.config.adx_strong_trend:
|
|
if ema_sig and ema_sig.signal in (SignalStrength.BUY, SignalStrength.STRONG_BUY):
|
|
result.market_regime = "trending_up"
|
|
elif ema_sig and ema_sig.signal in (SignalStrength.SELL, SignalStrength.STRONG_SELL):
|
|
result.market_regime = "trending_down"
|
|
else:
|
|
result.market_regime = "trending"
|
|
elif adx_sig and adx_slope > self.config.adx_slope_emerging_threshold:
|
|
result.market_regime = "transitioning"
|
|
else:
|
|
result.market_regime = "ranging"
|
|
|
|
def _compute_composite(self, result: TechnicalAnalysis):
|
|
"""
|
|
Compute the final composite signal score.
|
|
|
|
Weighted average of all indicator signals, adjusted for:
|
|
- Market regime (trend indicators weighted more in trends)
|
|
- Confidence scaling
|
|
"""
|
|
total_score = 0.0
|
|
total_weight = 0.0
|
|
total_confidence = 0.0
|
|
|
|
# Override weights if provided, else gold-optimized defaults
|
|
# (regime_weight_table is the single source of truth).
|
|
regime_weights = regime_weight_table(self.config)
|
|
|
|
active_regime_weights = regime_weights.get(result.market_regime, {})
|
|
|
|
for name, ind in result.indicators.items():
|
|
if name.startswith("_") or ind.weight == 0:
|
|
continue
|
|
|
|
weight = ind.weight
|
|
regime_mult = active_regime_weights.get(name, 1.0)
|
|
weight *= regime_mult
|
|
|
|
signal_score = ind.signal.value
|
|
weighted_score = signal_score * weight * (ind.confidence / 100.0)
|
|
# Record-keeping stamp (no maths depends on these two lines).
|
|
ind.effective_weight = float(weight)
|
|
ind.weighted_score = float(weighted_score)
|
|
|
|
total_score += weighted_score
|
|
total_weight += weight
|
|
total_confidence += ind.confidence * weight
|
|
|
|
if total_weight > 0:
|
|
normalized_score = (total_score / total_weight) * 50
|
|
avg_confidence = total_confidence / total_weight
|
|
else:
|
|
normalized_score = 0
|
|
avg_confidence = 0
|
|
|
|
normalized_score = max(-100, min(100, normalized_score))
|
|
|
|
if normalized_score >= 40:
|
|
result.composite_signal = SignalStrength.STRONG_BUY
|
|
elif normalized_score >= 15:
|
|
result.composite_signal = SignalStrength.BUY
|
|
elif normalized_score <= -40:
|
|
result.composite_signal = SignalStrength.STRONG_SELL
|
|
elif normalized_score <= -15:
|
|
result.composite_signal = SignalStrength.SELL
|
|
else:
|
|
result.composite_signal = SignalStrength.NEUTRAL
|
|
|
|
result.composite_score = normalized_score
|
|
result.confidence = avg_confidence
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# HELPER METHODS
|
|
# ══════════════════════════════════════════════════════════════
|
|
|
|
@staticmethod
|
|
def _find_swing_points(
|
|
series: pd.Series, mode: str = "low", window: int = 3,
|
|
) -> List[Tuple[int, float]]:
|
|
"""Find local swing highs or lows in a series."""
|
|
points = []
|
|
vals = series.values
|
|
for i in range(window, len(vals) - window):
|
|
if np.isnan(vals[i]):
|
|
continue
|
|
left_vals = [vals[i-j] for j in range(1, window+1) if not np.isnan(vals[i-j])]
|
|
right_vals = [vals[i+j] for j in range(1, window+1) if i+j < len(vals) and not np.isnan(vals[i+j])]
|
|
if not left_vals or not right_vals:
|
|
continue
|
|
if mode == "low":
|
|
if all(vals[i] <= v for v in left_vals) and all(vals[i] <= v for v in right_vals):
|
|
points.append((i, vals[i]))
|
|
else:
|
|
if all(vals[i] >= v for v in left_vals) and all(vals[i] >= v for v in right_vals):
|
|
points.append((i, vals[i]))
|
|
return points
|