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>
252 lines
10 KiB
Python
252 lines
10 KiB
Python
"""
|
||
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
|