Files
AH-Oil-Trader/core/elliott.py
T
Axel HocksandClaude Opus 4.8 75d28827e8 Initial commit: Oil Trading Bot (MT5, WTI)
Headless FastAPI-Backend (server.py + core/engine.py) mit Mobile-PWA (web/),
Strategie-/Backtest-Suite und Doku. Secrets, DB, Logs und Laufzeit-State sind
via .gitignore ausgeschlossen; Config-Vorlage: oil_widget_config.ini.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 08:29:23 +02:00

252 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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.3820.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