""" core/elliott.py — Elliott-Wave-/FVG-Heuristik ============================================== Prinzipienbasierter (NICHT perfekter) Elliott-Wave-Motor als Analyse-Input für den KI-Agenten. EW-Zählung ist diskretionär — dieser Motor liefert eine *plausible* Zählung mit Validitäts-Flag, keine Gewissheit. Was er macht: 1. ATR-ZigZag → Swing-Pivots (H/L) und Legs. 2. Impuls-Erkennung: 5 Legs als 1-2-3-4-5, geprüft gegen die drei harten EW-Regeln (W2 < Start, W3 nicht der kürzeste, W4 ohne W1-Überlappung). 3. Stand im Zyklus: vollständiger Impuls (→ Reversal-Watch) oder laufende Welle 5/3 (→ Fib-Extension-Ziel projizieren). 4. FVG-Erkennung (3-Kerzen-Imbalance) der jüngsten unfilled Gaps. 5. Erschöpfungs-Flag, wenn der Kurs das projizierte Ziel erreicht/überschritten hat. Output via snapshot() — wird in den Agent-Kontext gegeben, damit das LLM mit EW-Struktur (Zielzone, Erschöpfung, FVG) argumentiert. """ from __future__ import annotations import threading import time import MetaTrader5 as mt5 from core.mt5_utils import mt5_lock from core.logger import get_logger log = get_logger("elliott") _N_BARS = 240 _ATR_PERIOD = 14 _ZZ_ATR = 0.7 # Swing-Umkehr ab dieser ATR-Bewegung (etwas grober als wave_rec) _FVG_LOOKBACK = 60 # Kerzen, in denen nach offenen FVGs gesucht wird _STALE_S = 180 _TF_LABELS = { mt5.TIMEFRAME_M1: "M1", mt5.TIMEFRAME_M5: "M5", mt5.TIMEFRAME_M15: "M15", mt5.TIMEFRAME_M30: "M30", mt5.TIMEFRAME_H1: "H1", mt5.TIMEFRAME_H4: "H4", } def _atr(highs, lows, closes, period=_ATR_PERIOD): trs = [max(highs[i] - lows[i], abs(highs[i] - closes[i - 1]), abs(lows[i] - closes[i - 1])) for i in range(1, len(highs))] return (sum(trs[-period:]) / min(len(trs), period)) if trs else None def _zigzag(highs, lows, thr): """ATR-ZigZag → Liste (idx, price, kind 'H'/'L'), chronologisch.""" n = len(highs) pivots = [] direction = 0 hi_idx, hi = 0, highs[0] lo_idx, lo = 0, lows[0] for i in range(1, n): if highs[i] > hi: hi, hi_idx = highs[i], i if lows[i] < lo: lo, lo_idx = lows[i], i if direction >= 0 and hi - lows[i] >= thr: pivots.append((hi_idx, hi, "H")); direction = -1 lo, lo_idx = lows[i], i elif direction <= 0 and highs[i] - lo >= thr: pivots.append((lo_idx, lo, "L")); direction = 1 hi, hi_idx = highs[i], i return pivots def _detect_fvg(highs, lows, lookback=_FVG_LOOKBACK): """Fair Value Gaps (3-Kerzen-Imbalance) der jüngsten Kerzen, noch offen. Bullish FVG: low[i] > high[i-2] (Lücke nach oben). Bearish FVG: high[i] < low[i-2] (Lücke nach unten).""" n = len(highs) cur = (highs[-1] + lows[-1]) / 2.0 out = [] for i in range(max(2, n - lookback), n): if lows[i] > highs[i - 2]: # bullish FVG (Support unter dem Kurs) lo, hi = highs[i - 2], lows[i] if cur >= lo: # noch nicht nach unten durchbrochen out.append(("bullish", lo, hi)) elif highs[i] < lows[i - 2]: # bearish FVG (Widerstand über dem Kurs) lo, hi = highs[i], lows[i - 2] if cur <= hi: # noch nicht nach oben durchbrochen out.append(("bearish", lo, hi)) if not out: return None typ, lo, hi = out[-1] # jüngster offener FVG return {"type": typ, "low": round(lo, 3), "high": round(hi, 3), "mid": round((lo + hi) / 2.0, 3)} def _label_impulse(pivots): """Versucht, die letzten Pivots als 5-Wellen-Impuls zu labeln. Liefert dict mit Zählung + Validität oder None. Down-Impuls: H L H L H L (W1 L, W2 H, W3 L, W4 H, W5 L) Up-Impuls spiegelbildlich.""" if len(pivots) < 5: return None # bis zu 6 letzte Pivots betrachten p = pivots[-6:] prices = [x[1] for x in p] kinds = [x[2] for x in p] # Richtung aus dem Muster: beginnt mit H → Down-Impuls, mit L → Up-Impuls # Wir brauchen alternierende Kinds. if any(kinds[i] == kinds[i + 1] for i in range(len(kinds) - 1)): return None # nicht sauber alternierend down = kinds[0] == "H" # Indizes der Wellen-Endpunkte (Start=p[0]) # 5 Legs brauchen 6 Pivots; bei 5 Pivots ist W5 noch offen. have = len(p) def leg(a, b): return abs(prices[b] - prices[a]) if have >= 6: start, w1, w2, w3, w4, w5 = prices[-6:] L1, L3, L5 = leg(-6, -5), leg(-4, -3), leg(-2, -1) # EW-Regeln if down: r2 = w2 < start # W2-Hoch unter Start r4 = w4 < w1 # W4-Hoch unter W1-Tief (keine Überlappung) else: r2 = w2 > start r4 = w4 > w1 r3 = L3 >= min(L1, L5) and not (L3 < L1 and L3 < L5) # W3 nicht der kürzeste valid = r2 and r3 and r4 return {"pattern": "impulse_down" if down else "impulse_up", "wave": "5", "complete": True, "valid": valid, "w5_end": round(prices[-1], 3), "w4_end": round(prices[-2], 3), "w1_len": round(L1, 3), "dir": "down" if down else "up"} else: # 5 Pivots: W4 fertig, W5 läuft noch start, w1, w2, w3, w4 = prices[-5:] L1, L3 = leg(-5, -4), leg(-3, -2) if down: r2 = w2 < start; r4 = w4 < w1 else: r2 = w2 > start; r4 = w4 > w1 r3 = L3 >= L1 * 0.6 # W3 mindestens vergleichbar mit W1 valid = r2 and r3 and r4 return {"pattern": "impulse_down" if down else "impulse_up", "wave": "5", "complete": False, "valid": valid, "w4_end": round(prices[-1], 3), "w1_len": round(L1, 3), "dir": "down" if down else "up"} class ElliottAnalyzer: def __init__(self, timeframe: int = mt5.TIMEFRAME_M15): self._lock = threading.Lock() self._tf = timeframe self._snap: dict = {} self._ts: float = 0.0 self._error: str | None = None def set_timeframe(self, tf: int): with self._lock: self._tf = tf self._snap = {} self._ts = 0.0 def refresh_market(self, sym: str): with self._lock: tf = self._tf with mt5_lock(timeout=2) as got: if not got: return bars = mt5.copy_rates_from_pos(sym, tf, 0, _N_BARS) if bars is None or len(bars) < _ATR_PERIOD + 10: with self._lock: self._error = "keine Bars" return highs = [float(b["high"]) for b in bars] lows = [float(b["low"]) for b in bars] closes = [float(b["close"]) for b in bars] atr = _atr(highs, lows, closes) if not atr or atr <= 0: with self._lock: self._error = "ATR=0" return cur = closes[-1] pivots = _zigzag(highs, lows, _ZZ_ATR * atr) fvg = _detect_fvg(highs, lows) snap = self._build(pivots, cur, atr, fvg, tf) with self._lock: self._snap = snap self._ts = time.time() self._error = None # Nur bei ÄNDERUNG loggen — lief vorher je Tick (~6k identische Zeilen # pro 5-MB-Logrotation) und flutete das Log. line = (f"{sym} {snap.get('pattern')}/{snap.get('wave')} " f"target={snap.get('target')} exhausted={snap.get('exhaustion')}") if line != getattr(self, "_last_log_line", None): self._last_log_line = line log.info(line) def _build(self, pivots, cur, atr, fvg, tf): tf_lbl = _TF_LABELS.get(tf, str(tf)) out = {"tf": tf_lbl, "pattern": "unclear", "wave": "?", "dir": None, "target": None, "target_label": None, "exhaustion": False, "invalidation": None, "valid": False, "fvg": fvg, "n_pivots": len(pivots), "note": ""} imp = _label_impulse(pivots) if not imp: out["note"] = "kein sauberer Impuls erkennbar" return out out.update(pattern=imp["pattern"], dir=imp["dir"], valid=imp["valid"]) down = imp["dir"] == "down" if not imp["complete"]: # Welle 5 läuft → Ziel = W4-Ende ∓ (1.0 / 1.618) × W1-Länge w4 = imp["w4_end"]; l1 = imp["w1_len"] t100 = w4 - l1 if down else w4 + l1 t162 = w4 - 1.618 * l1 if down else w4 + 1.618 * l1 out["wave"] = "5 (laufend)" out["target"] = round(t162, 3) out["target_label"] = "1.618 W5" out["invalidation"] = round(w4, 3) # über/unter W4 = Zählung fraglich # Erschöpfung, wenn Kurs das 1.0-Ziel erreicht/überschritten hat reached = (cur <= t100) if down else (cur >= t100) out["exhaustion"] = reached out["note"] = (f"Welle 5 {'abwärts' if down else 'aufwärts'} läuft, " f"Ziel ~{out['target']} (1.0 bei ~{round(t100,3)})" + (" — Ziel erreicht, Reversal-Risiko" if reached else "")) else: # Impuls vollständig → Reversal in Gegenrichtung wahrscheinlich w5 = imp["w5_end"]; l1 = imp["w1_len"] out["wave"] = "5 (vollendet)" out["dir"] = imp["dir"] # Reversal-Ziele = Fib-Retracement des Gesamtimpulses (grob via W1-Länge) out["target"] = round((w5 + l1) if down else (w5 - l1), 3) out["target_label"] = "Reversal ~0.382–0.618" out["invalidation"] = round(w5, 3) # nach Vollendung gilt der Impuls als erschöpft out["exhaustion"] = True out["note"] = (f"Impuls {'abwärts' if down else 'aufwärts'} vollendet bei " f"{w5} → Reversal {'aufwärts' if down else 'abwärts'} wahrscheinlich") return out def snapshot(self) -> dict: with self._lock: d = dict(self._snap) d["error"] = self._error d["last_update"] = self._ts d["stale"] = (not self._ts) or (time.time() - self._ts > _STALE_S) return d