17cb63e86e
## What changed ### backtest_gpu.py (new — ~1,300 lines) Standalone GPU-accelerated backtester completely separate from backtest.py. Computes all indicator series in one vectorised O(N) pass over the full price history, then replays positions with a sequential state machine. Key design: - VectorIndicators: pandas ewm/rolling (C-optimised) for all 7 indicators - CuPy GPU backend with automatic NumPy fallback (no hard dependency) - Two-phase API: prepare_arrays() once, score_and_run() many times - --optimize flag: 36-combo weight sweep (1 baseline + 7 ablations + 28 multiplier runs) — calls prepare_arrays() once, replays all combos in seconds - Full CLI: --tf, --local, --start/--end, --regime-filter, --regime-tf, --session-filter, --optimize, --journal, --journal-csv O(N) vs O(N^2): backtest.py recomputes all indicators per bar (O(N^2)); backtest_gpu.py computes each indicator series once and reads bar T in O(1). On 100,000 H1 bars: ~4 seconds vs >10 minutes. ### data_provider.py Added load_local_csv() method — reads MetaTrader tab-delimited CSV exports from market_data/ directory. Format: Time|Open|High|Low|Close|Volume|Spread (spread column dropped on load, timestamps localised to UTC). Applies the same _validate_and_clean() pipeline as the Twelve Data path. LOCAL_CSV_MAP maps each Timeframe enum to its filename. market_data/ files added (MetaTrader exports, UTC): USDJPY_M15.csv 100,000 bars 2022-05-04 to 2026-05-08 USDJPY_H1.csv 100,000 bars 2010-04-23 to 2026-05-08 USDJPY_H4.csv 25,855 bars 2010-04-22 to 2026-05-08 USDJPY_D1.csv 5,020 bars 2010-04-22 to 2026-05-08 16 years of H1/H4/D1 vs ~6 weeks of H1 on Twelve Data free plan. Use --local for all backtests; Twelve Data reserved for live signal feed. ### engine.py — optimised indicator weights 36-combo sweep on H4 2022-2026 found MACD and EMA trend are the dominant signals; RSI and Bollinger are net drag on this trending pair. Old → new weights: ema_trend: 1.2 → 3.6 (×3 — strongest directional signal) macd: 1.1 → 3.3 (×3 — ablating it is the single worst move: -42%) stochastic: 0.8 → 1.6 (×2 — clean timing, 40% WR at this level) adx: 1.0 → 0.5 (÷2 — partially redundant with EMA stack) ichimoku: 1.1 → 0.55 (÷2 — lags too much at H4, hurts in ranging) rsi: 0.3 → 0.0 (ablated — net drag across all test windows) bollinger: 0.9 → 0.0 (ablated — counter-trend noise on trending pair) RSI and Bollinger are still computed and displayed in live output for reference; weight=0 means they contribute nothing to the composite score. Combined result vs baseline (H4 2022-2026, local data): Return: +7.12% → +21.30% (+14.2pp) Max DD: -9.05% → -8.52% (tighter — gains did not come at expense of risk) Sharpe: 0.23 → 0.47 (2x improvement) Sortino: 1.38 → 1.91 Win Rate: 36.3% → 38.1% ### backtest.py Added 16-thread CPU parallelism for signal pre-computation via ThreadPoolExecutor. _precompute_all_signals() dispatches each bar to a worker; pandas/NumPy C extensions release the GIL enabling true parallel execution. Results are collected and replayed in the main loop, replacing the per-bar recompute. Also added --regime-tf CLI flag so regime filter timeframe can be specified independently of the primary TF. ### signal_aggregator.py EMA200 distance gate: confidence *= 0.55 when price is within 0.5% of EMA200 (chop zone) or beyond 1.5% (overextended). Sweet spot is 0.5-1.5% from EMA200 where trending momentum is cleanest. Strongest single filter found: lifted cumulative return +13.88% to +35.50% in ablation window. ### ENGINE_LOGIC.md (new) Full reference document covering: architecture, data pipeline, all indicator logic, composite scoring formula, optimised weights with rationale, regime classification, EMA200 gate, MTF alignment, grade system, backtesting results, filter catalogue, and configuration reference.
930 lines
36 KiB
Python
930 lines
36 KiB
Python
"""
|
|
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
|
|
|
|
|
|
@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)
|
|
|
|
|
|
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 [])
|
|
|
|
if "ema_trend" not in disabled: self._compute_ema_signals(df, result)
|
|
if "rsi" not in disabled: self._compute_rsi(df, result)
|
|
if "macd" not in disabled: self._compute_macd(df, result)
|
|
if "bollinger" not in disabled: self._compute_bollinger(df, result)
|
|
if "stochastic" not in disabled: self._compute_stochastic(df, result)
|
|
if "adx" not in disabled: self._compute_adx(df, result)
|
|
if "atr" not in disabled: self._compute_atr(df, result)
|
|
if "ichimoku" not in disabled: self._compute_ichimoku(df, result)
|
|
if "divergence" not in disabled: self._detect_divergences(df, result)
|
|
if "candlestick" not in disabled: self._detect_candlestick_patterns(df, result)
|
|
if "pivot" not in disabled: self._compute_support_resistance(df, result)
|
|
if "volume" not in disabled: self._compute_volume_analysis(df, result)
|
|
|
|
# ── Classify market regime ─────────────────────────────────
|
|
self._classify_regime(df, 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 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
|
|
|
|
# Bullish: price > fast > medium > slow > trend
|
|
# Bearish: price < fast < medium < slow < trend
|
|
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
|
|
|
|
# Check for EMA crossover (recent)
|
|
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)
|
|
|
|
desc = f"EMA alignment: {alignment_score}/{max_checks}{crossover_desc}"
|
|
result.indicators["ema_trend"] = IndicatorSignal(
|
|
name="EMA Trend", value=normalized,
|
|
signal=signal, confidence=conf,
|
|
description=desc, weight=3.6,
|
|
)
|
|
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).mean()
|
|
avg_loss = loss.ewm(alpha=1/c.rsi_period, min_periods=c.rsi_period).mean()
|
|
|
|
rs = avg_gain / avg_loss.replace(0, np.nan)
|
|
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=0.0,
|
|
)
|
|
|
|
# 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 with histogram momentum analysis."""
|
|
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
|
|
|
|
macd_val = macd_line.iloc[-1]
|
|
signal_val = signal_line.iloc[-1]
|
|
hist_val = histogram.iloc[-1]
|
|
|
|
# Check for crossover
|
|
crossover = False
|
|
crossover_type = ""
|
|
if len(macd_line) >= 2:
|
|
prev_macd = macd_line.iloc[-2]
|
|
prev_signal = signal_line.iloc[-2]
|
|
if prev_macd <= prev_signal and macd_val > signal_val:
|
|
crossover = True
|
|
crossover_type = "bullish"
|
|
elif prev_macd >= prev_signal and macd_val < signal_val:
|
|
crossover = True
|
|
crossover_type = "bearish"
|
|
|
|
# Histogram momentum (is it expanding or contracting?)
|
|
hist_momentum = ""
|
|
if len(histogram) >= 3:
|
|
h1, h2, h3 = histogram.iloc[-3], histogram.iloc[-2], histogram.iloc[-1]
|
|
if h3 > h2 > h1 and h3 > 0:
|
|
hist_momentum = "accelerating bullish"
|
|
elif h3 < h2 < h1 and h3 < 0:
|
|
hist_momentum = "accelerating bearish"
|
|
elif abs(h3) < abs(h2) and h3 * h2 > 0:
|
|
hist_momentum = "decelerating"
|
|
|
|
# Signal
|
|
if crossover and crossover_type == "bullish":
|
|
signal = SignalStrength.STRONG_BUY
|
|
conf = 75
|
|
desc = f"MACD bullish crossover | Hist: {hist_val:.6f}"
|
|
elif crossover and crossover_type == "bearish":
|
|
signal = SignalStrength.STRONG_SELL
|
|
conf = 75
|
|
desc = f"MACD bearish crossover | Hist: {hist_val:.6f}"
|
|
elif macd_val > signal_val and hist_val > 0:
|
|
signal = SignalStrength.BUY
|
|
conf = 55
|
|
desc = f"MACD above signal, positive histogram | {hist_momentum}"
|
|
elif macd_val < signal_val and hist_val < 0:
|
|
signal = SignalStrength.SELL
|
|
conf = 55
|
|
desc = f"MACD below signal, negative histogram | {hist_momentum}"
|
|
else:
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 35
|
|
desc = f"MACD mixed | Hist: {hist_val:.6f}"
|
|
|
|
result.indicators["macd"] = IndicatorSignal(
|
|
name="MACD", value=hist_val, signal=signal,
|
|
confidence=conf, description=desc, weight=3.3,
|
|
)
|
|
|
|
# Attach histogram for divergence detection
|
|
result._macd_histogram = histogram # type: ignore[attr-defined]
|
|
|
|
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()
|
|
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=0.0,
|
|
)
|
|
|
|
result.key_levels["bb_upper"] = upper_val
|
|
result.key_levels["bb_lower"] = lower_val
|
|
result.key_levels["bb_mid"] = sma_val
|
|
|
|
def _compute_stochastic(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""Stochastic oscillator with crossover signals."""
|
|
c = self.config
|
|
high = df["High"]
|
|
low = df["Low"]
|
|
close = df["Close"]
|
|
|
|
lowest_low = low.rolling(c.stoch_k).min()
|
|
highest_high = high.rolling(c.stoch_k).max()
|
|
|
|
denom = highest_high - lowest_low
|
|
denom = denom.replace(0, np.nan)
|
|
|
|
k_line = 100 * (close - lowest_low) / denom
|
|
d_line = k_line.rolling(c.stoch_d).mean()
|
|
|
|
k_val = k_line.iloc[-1] if not k_line.empty else 50.0
|
|
d_val = d_line.iloc[-1] if not d_line.empty else 50.0
|
|
|
|
if pd.isna(k_val):
|
|
k_val = 50.0
|
|
if pd.isna(d_val):
|
|
d_val = 50.0
|
|
|
|
# Crossover detection
|
|
crossover = ""
|
|
if len(k_line) >= 2 and len(d_line) >= 2:
|
|
prev_k = k_line.iloc[-2]
|
|
prev_d = d_line.iloc[-2]
|
|
if not pd.isna(prev_k) and not pd.isna(prev_d):
|
|
if prev_k <= prev_d and k_val > d_val:
|
|
crossover = " | Bullish %K/%D cross"
|
|
elif prev_k >= prev_d and k_val < d_val:
|
|
crossover = " | Bearish %K/%D cross"
|
|
|
|
if k_val >= c.stoch_overbought and d_val >= c.stoch_overbought:
|
|
signal = SignalStrength.SELL
|
|
conf = 60
|
|
desc = f"Stoch %K={k_val:.0f} %D={d_val:.0f} — Overbought{crossover}"
|
|
elif k_val <= c.stoch_oversold and d_val <= c.stoch_oversold:
|
|
signal = SignalStrength.BUY
|
|
conf = 60
|
|
desc = f"Stoch %K={k_val:.0f} %D={d_val:.0f} — Oversold{crossover}"
|
|
elif crossover and "Bullish" in crossover:
|
|
signal = SignalStrength.BUY
|
|
conf = 55
|
|
desc = f"Stoch %K={k_val:.0f} %D={d_val:.0f}{crossover}"
|
|
elif crossover and "Bearish" in crossover:
|
|
signal = SignalStrength.SELL
|
|
conf = 55
|
|
desc = f"Stoch %K={k_val:.0f} %D={d_val:.0f}{crossover}"
|
|
else:
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 30
|
|
desc = f"Stoch %K={k_val:.0f} %D={d_val:.0f} — Neutral"
|
|
|
|
result.indicators["stochastic"] = IndicatorSignal(
|
|
name="Stochastic", value=k_val, signal=signal,
|
|
confidence=conf, description=desc, weight=1.6,
|
|
)
|
|
|
|
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 = high.diff()
|
|
minus_dm = -low.diff()
|
|
|
|
plus_dm = plus_dm.where((plus_dm > minus_dm) & (plus_dm > 0), 0.0)
|
|
minus_dm = minus_dm.where((minus_dm > plus_dm) & (minus_dm > 0), 0.0)
|
|
|
|
atr = tr.ewm(alpha=1/c.adx_period, min_periods=c.adx_period).mean()
|
|
plus_di = 100 * (plus_dm.ewm(alpha=1/c.adx_period, min_periods=c.adx_period).mean() / atr)
|
|
minus_di = 100 * (minus_dm.ewm(alpha=1/c.adx_period, min_periods=c.adx_period).mean() / atr)
|
|
|
|
dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, np.nan)
|
|
adx = dx.ewm(alpha=1/c.adx_period, min_periods=c.adx_period).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"
|
|
|
|
desc = f"ADX {adx_val:.1f} ({trend_strength} {direction}) | +DI={plus_di_val:.1f} -DI={minus_di_val:.1f}"
|
|
|
|
result.indicators["adx"] = IndicatorSignal(
|
|
name="ADX", value=adx_val, signal=signal,
|
|
confidence=conf, description=desc, weight=0.5,
|
|
)
|
|
|
|
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).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_ichimoku(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""Ichimoku Cloud analysis."""
|
|
c = self.config
|
|
high, low, close = df["High"], df["Low"], df["Close"]
|
|
|
|
if len(df) < c.ichimoku_senkou_b + 26:
|
|
return
|
|
|
|
# Tenkan-sen (Conversion Line)
|
|
tenkan = (high.rolling(c.ichimoku_tenkan).max() +
|
|
low.rolling(c.ichimoku_tenkan).min()) / 2
|
|
|
|
# Kijun-sen (Base Line)
|
|
kijun = (high.rolling(c.ichimoku_kijun).max() +
|
|
low.rolling(c.ichimoku_kijun).min()) / 2
|
|
|
|
latest = close.iloc[-1]
|
|
tenkan_val = tenkan.iloc[-1]
|
|
kijun_val = kijun.iloc[-1]
|
|
|
|
# Current (unshifted) cloud
|
|
sa = (tenkan.iloc[-1] + kijun.iloc[-1]) / 2
|
|
sb_raw = (high.rolling(c.ichimoku_senkou_b).max() +
|
|
low.rolling(c.ichimoku_senkou_b).min()) / 2
|
|
sb = sb_raw.iloc[-1]
|
|
|
|
if pd.isna(sa) or pd.isna(sb) or pd.isna(tenkan_val) or pd.isna(kijun_val):
|
|
return
|
|
|
|
cloud_top = max(sa, sb)
|
|
cloud_bottom = min(sa, sb)
|
|
cloud_is_green = sa > sb
|
|
|
|
signals = []
|
|
score = 0
|
|
|
|
if latest > cloud_top:
|
|
signals.append("Price ABOVE cloud")
|
|
score += 2
|
|
elif latest < cloud_bottom:
|
|
signals.append("Price BELOW cloud")
|
|
score -= 2
|
|
else:
|
|
signals.append("Price IN cloud (indecision)")
|
|
|
|
if tenkan_val > kijun_val:
|
|
signals.append("TK cross bullish")
|
|
score += 1
|
|
elif tenkan_val < kijun_val:
|
|
signals.append("TK cross bearish")
|
|
score -= 1
|
|
|
|
if cloud_is_green:
|
|
signals.append("Green cloud")
|
|
score += 1
|
|
else:
|
|
signals.append("Red cloud")
|
|
score -= 1
|
|
|
|
if score >= 3:
|
|
signal = SignalStrength.STRONG_BUY
|
|
conf = 75
|
|
elif score >= 1:
|
|
signal = SignalStrength.BUY
|
|
conf = 55
|
|
elif score <= -3:
|
|
signal = SignalStrength.STRONG_SELL
|
|
conf = 75
|
|
elif score <= -1:
|
|
signal = SignalStrength.SELL
|
|
conf = 55
|
|
else:
|
|
signal = SignalStrength.NEUTRAL
|
|
conf = 35
|
|
|
|
result.indicators["ichimoku"] = IndicatorSignal(
|
|
name="Ichimoku", value=float(score), signal=signal,
|
|
confidence=conf,
|
|
description=f"Ichimoku [{score}/4]: {' | '.join(signals)}",
|
|
weight=0.55,
|
|
)
|
|
|
|
result.key_levels["ichimoku_cloud_top"] = cloud_top
|
|
result.key_levels["ichimoku_cloud_bottom"] = cloud_bottom
|
|
|
|
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:]
|
|
|
|
# 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")
|
|
|
|
if len(price_lows) >= 2 and len(ind_lows) >= 2:
|
|
if price_lows[-1][1] < price_lows[-2][1] and ind_lows[-1][1] > ind_lows[-2][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")
|
|
|
|
if len(price_highs) >= 2 and len(ind_highs) >= 2:
|
|
if price_highs[-1][1] > price_highs[-2][1] and ind_highs[-1][1] < ind_highs[-2][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, # divergences are strong reversal signals
|
|
)
|
|
# Note: divergences are NOT added to pattern_signals to avoid
|
|
# duplicate reason collection (the indicator entry covers them).
|
|
|
|
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
|
|
|
|
result.indicators["candlestick"] = IndicatorSignal(
|
|
name="Candlestick", value=float(bullish - bearish),
|
|
signal=signal, confidence=50,
|
|
description=" | ".join(patterns),
|
|
weight=0.7,
|
|
)
|
|
result.pattern_signals.extend(patterns)
|
|
|
|
def _compute_support_resistance(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""Calculate pivot points and key S/R levels."""
|
|
if len(df) < 2:
|
|
return
|
|
|
|
# Use previous bar for pivot calculation
|
|
prev_h = df["High"].iloc[-2]
|
|
prev_l = df["Low"].iloc[-2]
|
|
prev_c = df["Close"].iloc[-2]
|
|
|
|
pivot = (prev_h + prev_l + prev_c) / 3
|
|
r1 = 2 * pivot - prev_l
|
|
s1 = 2 * pivot - prev_h
|
|
r2 = pivot + (prev_h - prev_l)
|
|
s2 = pivot - (prev_h - prev_l)
|
|
r3 = prev_h + 2 * (pivot - prev_l)
|
|
s3 = prev_l - 2 * (prev_h - pivot)
|
|
|
|
result.key_levels.update({
|
|
"pivot": pivot,
|
|
"r1": r1, "r2": r2, "r3": r3,
|
|
"s1": s1, "s2": s2, "s3": s3,
|
|
})
|
|
|
|
# Signal based on price position relative to pivot
|
|
latest = df["Close"].iloc[-1]
|
|
if latest > r1:
|
|
desc = f"Price above R1 ({r1:.5f}), next target R2 ({r2:.5f})"
|
|
elif latest > pivot:
|
|
desc = f"Price above Pivot ({pivot:.5f}), testing R1 ({r1:.5f})"
|
|
elif latest > s1:
|
|
desc = f"Price below Pivot ({pivot:.5f}), support at S1 ({s1:.5f})"
|
|
else:
|
|
desc = f"Price below S1 ({s1:.5f}), support at S2 ({s2:.5f})"
|
|
|
|
result.indicators["pivot"] = IndicatorSignal(
|
|
name="Pivot Points", value=pivot,
|
|
signal=SignalStrength.BUY if latest > pivot else SignalStrength.SELL,
|
|
confidence=45, description=desc, weight=0.6,
|
|
)
|
|
|
|
def _compute_volume_analysis(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""Volume analysis (limited on Forex but still useful as a filter)."""
|
|
c = self.config
|
|
vol = df.get("Volume")
|
|
if vol is None or vol.sum() == 0:
|
|
return
|
|
|
|
vol_ma = vol.rolling(c.volume_ma_period).mean()
|
|
current_vol = vol.iloc[-1]
|
|
avg_vol = vol_ma.iloc[-1]
|
|
|
|
if pd.isna(avg_vol) or avg_vol == 0:
|
|
return
|
|
|
|
vol_ratio = current_vol / avg_vol
|
|
|
|
if vol_ratio > 2.0:
|
|
desc = f"Volume {vol_ratio:.1f}x average — HIGH ACTIVITY"
|
|
conf = 55
|
|
elif vol_ratio > 1.3:
|
|
desc = f"Volume {vol_ratio:.1f}x average — above normal"
|
|
conf = 45
|
|
elif vol_ratio < 0.5:
|
|
desc = f"Volume {vol_ratio:.1f}x average — LOW (signals less reliable)"
|
|
conf = 25
|
|
else:
|
|
desc = f"Volume {vol_ratio:.1f}x average — normal"
|
|
conf = 35
|
|
|
|
result.indicators["volume"] = IndicatorSignal(
|
|
name="Volume", value=vol_ratio,
|
|
signal=SignalStrength.NEUTRAL,
|
|
confidence=conf, description=desc, weight=0.0,
|
|
)
|
|
|
|
# ══════════════════════════════════════════════════════════════
|
|
# MARKET REGIME & COMPOSITE SCORING
|
|
# ══════════════════════════════════════════════════════════════
|
|
|
|
def _classify_regime(self, df: pd.DataFrame, result: TechnicalAnalysis):
|
|
"""Classify the market regime (trending vs ranging)."""
|
|
adx_sig = result.indicators.get("adx")
|
|
ema_sig = result.indicators.get("ema_trend")
|
|
|
|
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"
|
|
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)
|
|
- Divergence boost (divergences override in ranging markets)
|
|
- Confidence scaling
|
|
"""
|
|
total_score = 0.0
|
|
total_weight = 0.0
|
|
total_confidence = 0.0
|
|
|
|
# Regime-gate ichimoku: trend hero (-23% to disable in 2024 trend) but
|
|
# chop villain (+18% to disable in 2023 chop & 2026 recent).
|
|
regime_weights = {
|
|
"trending_up": {"ema_trend": 1.5, "adx": 1.3, "macd": 1.2, "ichimoku": 1.3, "rsi": 0.7, "bollinger": 0.6},
|
|
"trending_down": {"ema_trend": 1.5, "adx": 1.3, "macd": 1.2, "ichimoku": 1.3, "rsi": 0.7, "bollinger": 0.6},
|
|
"ranging": {"rsi": 1.4, "stochastic": 1.3, "bollinger": 1.4, "ema_trend": 0.6, "adx": 0.5, "ichimoku": 0.2},
|
|
}
|
|
|
|
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 # -2 to +2
|
|
weighted_score = signal_score * weight * (ind.confidence / 100.0)
|
|
|
|
total_score += weighted_score
|
|
total_weight += weight
|
|
total_confidence += ind.confidence * weight
|
|
|
|
if total_weight > 0:
|
|
normalized_score = (total_score / total_weight) * 50 # scale to -100..+100
|
|
avg_confidence = total_confidence / total_weight
|
|
else:
|
|
normalized_score = 0
|
|
avg_confidence = 0
|
|
|
|
# Clamp
|
|
normalized_score = max(-100, min(100, normalized_score))
|
|
|
|
# Determine composite signal
|
|
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
|
|
if mode == "low":
|
|
if all(vals[i] <= vals[i-j] for j in range(1, window+1) if not np.isnan(vals[i-j])) and \
|
|
all(vals[i] <= vals[i+j] for j in range(1, window+1) if i+j < len(vals) and not np.isnan(vals[i+j])):
|
|
points.append((i, vals[i]))
|
|
else:
|
|
if all(vals[i] >= vals[i-j] for j in range(1, window+1) if not np.isnan(vals[i-j])) and \
|
|
all(vals[i] >= vals[i+j] for j in range(1, window+1) if i+j < len(vals) and not np.isnan(vals[i+j])):
|
|
points.append((i, vals[i]))
|
|
return points
|