Files
ForexBot2/engine.py
T
Harwinder e30535f5e9 GPU backtester, optimised weights, local CSV data pipeline
## 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.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 22:12:43 -04:00

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