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:
+251
@@ -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.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
|
||||
Reference in New Issue
Block a user