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>
This commit is contained in:
Axel Hocks
2026-07-24 08:29:23 +02:00
co-authored by Claude Opus 4.8
commit 75d28827e8
104 changed files with 21059 additions and 0 deletions
+251
View File
@@ -0,0 +1,251 @@
"""
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