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
+247
View File
@@ -0,0 +1,247 @@
"""
core/structure.py — Marktstruktur-Erkennung (ANZEIGE, kein Signal)
==================================================================
Erkennt aus den M30-Bars die klassische Price-Action-Struktur und liefert sie
als Kontext fürs Dashboard:
• Swing-Folge HH / HL / LH / LL (Pivot-Hochs/-Tiefs, jeweils vs. Vorgänger)
• letzter BOS (Break of Structure: Richtung + gebrochenes Level)
• Regressionskanal (Richtung + Position des Kurses im Kanal 0..1)
• Gesamt-Struktur up / down / range
REINE ANZEIGE — wie TF-Ampel/Squeeze/Bounce: KEIN Trade-Trigger, KEIN Verdict-
Gewicht. Die handelbaren Varianten sind separat gemessen & verworfen:
BOS-Entry ≈ Momentum-Continuation (`backtest_momentum.py`, regime-abhängig),
Kanal-/Zonen-Bounce ≈ P(break)-Level-Bounce (6× belegt: Münzwurf am Extrem).
Deshalb malt dieses Modul KEINE Richtung/Prognose — es beschreibt nur den Ist-Zustand.
Thread-sicher: refresh_market(sym) holt die Bars unter mt5_lock (~30 s gedrosselt),
snapshot() liefert den letzten Stand ohne MT5-Call.
"""
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("structure")
_TF = mt5.TIMEFRAME_M30 # Struktur auf M30 (klare Swings, wie der Referenz-Chart)
_N_BARS = 220
_PIVOT_K = 3 # Swing-Pivot-Fenster (k Bars je Seite)
_REG_N = 60 # Regressionsfenster für den Kanal (~30 h auf M30)
_SLOPE_DEAD = 0.015 # |Steigung/Bar| < dead×ATR → Kanal "flat"
_MAX_SWINGS = 6 # so viele letzte Swings ausgeben
_REFRESH_S = 30.0 # Drossel (Struktur ändert sich langsam, spart Lock-Zeit)
def _atr(highs, lows, closes, p=14):
trs = []
for i in range(1, len(closes)):
trs.append(max(highs[i]-lows[i], abs(highs[i]-closes[i-1]), abs(lows[i]-closes[i-1])))
return (sum(trs[-p:]) / min(len(trs), p)) if trs else None
def _pivots(highs, lows, k):
"""Alternierende Swing-Punkte → Liste (index, price, kind) mit kind 'H'/'L'.
Swing-High bei i: höchster Bar im Fenster [i-k .. i+k] und lokales Maximum."""
n = len(highs)
raw = []
for i in range(k, n - k):
win_hi = max(highs[i-k:i+k+1]); win_lo = min(lows[i-k:i+k+1])
if highs[i] == win_hi and highs[i] > highs[i-1] and highs[i] >= highs[i+1]:
raw.append((i, highs[i], "H"))
elif lows[i] == win_lo and lows[i] < lows[i-1] and lows[i] <= lows[i+1]:
raw.append((i, lows[i], "L"))
# Alternierung erzwingen: zwei gleiche Typen in Folge → den extremeren behalten
out = []
for p in raw:
if out and out[-1][2] == p[2]:
if (p[2] == "H" and p[1] > out[-1][1]) or (p[2] == "L" and p[1] < out[-1][1]):
out[-1] = p
else:
out.append(p)
return out
def _classify(pivots):
"""Swing-Folge als HH/HL/LH/LL (vs. jeweils vorheriges High bzw. Low)."""
labels = []
last_h = last_l = None
for idx, price, kind in pivots:
if kind == "H":
lab = ("HH" if (last_h is not None and price > last_h)
else "LH" if last_h is not None else "H")
last_h = price
else:
lab = ("HL" if (last_l is not None and price > last_l)
else "LL" if last_l is not None else "L")
last_l = price
labels.append({"type": lab, "price": round(price, 3), "idx": idx})
return labels
def _trend_state(labels):
recent = [l["type"] for l in labels[-4:]]
ups = sum(1 for t in recent if t in ("HH", "HL"))
dns = sum(1 for t in recent if t in ("LH", "LL"))
if ups >= 3 and ups > dns:
return "up"
if dns >= 3 and dns > ups:
return "down"
return "range"
def _last_bos(labels, n_bars):
"""Letzter Break of Structure: jüngstes HH (bullisch, Vorlauf-Hoch gebrochen)
bzw. LL (bärisch). Level = das gebrochene vorige Extrem; bars_ago aus dem Index."""
prev_h = prev_l = None
bos = None
for l in labels:
if l["type"] in ("HH", "LH"):
if l["type"] == "HH" and prev_h is not None:
bos = {"dir": "up", "level": prev_h, "idx": l["idx"]}
prev_h = l["price"]
else:
if l["type"] == "LL" and prev_l is not None:
bos = {"dir": "down", "level": prev_l, "idx": l["idx"]}
prev_l = l["price"]
if bos:
bos["bars_ago"] = max(0, (n_bars - 1) - bos.pop("idx"))
bos["level"] = round(bos["level"], 3)
return bos
def _channel(closes, atr):
N = min(_REG_N, len(closes))
if N < 5:
return None
ys = closes[-N:]
mx = (N - 1) / 2.0
my = sum(ys) / N
sxx = sum((x - mx) ** 2 for x in range(N))
sxy = sum((x - mx) * (ys[x] - my) for x in range(N))
slope = sxy / sxx if sxx else 0.0
intercept = my - slope * mx
resid = [ys[x] - (slope * x + intercept) for x in range(N)]
up_off, lo_off = max(resid), min(resid)
last_x = N - 1
mid = slope * last_x + intercept
upper, lower = mid + up_off, mid + lo_off
width = upper - lower
pos = (ys[-1] - lower) / width if width > 0 else 0.5
if atr and abs(slope) < _SLOPE_DEAD * atr:
d = "flat"
else:
d = "up" if slope > 0 else "down"
return {"dir": d, "pos": round(max(0.0, min(1.0, pos)), 2),
"upper": round(upper, 3), "lower": round(lower, 3), "mid": round(mid, 3),
"slope_atr": round(slope / atr, 3) if atr else None}
def _channel_anchors(closes, times, atr):
"""Kanal als 2 Ankerpunkte je Linie (Fensterstart + letzter abgeschl. Bar) mit
BROKER-Zeiten — für die MQL5-Bridge (OBJ_TREND, nach rechts verlängert).
closes/times = abgeschlossene Bars (gleich lang). Gibt {t1,t2,dir,upper,mid,lower}."""
N = min(_REG_N, len(closes))
if N < 5 or len(times) < N:
return None
seg = closes[-N:]; tt = times[-N:]
mx = (N - 1) / 2.0; my = sum(seg) / N
sxx = sum((x - mx) ** 2 for x in range(N))
sxy = sum((x - mx) * (seg[x] - my) for x in range(N))
b = sxy / sxx if sxx else 0.0
a = my - b * mx
resid = [seg[x] - (a + b * x) for x in range(N)]
up_off, lo_off = max(resid), min(resid)
m1 = a; m2 = a + b * (N - 1)
d = "flat" if (atr and abs(b) < _SLOPE_DEAD * atr) else ("up" if b > 0 else "down")
return {"t1": int(tt[0]), "t2": int(tt[-1]), "dir": d,
"upper": [round(m1 + up_off, 3), round(m2 + up_off, 3)],
"mid": [round(m1, 3), round(m2, 3)],
"lower": [round(m1 + lo_off, 3), round(m2 + lo_off, 3)]}
def channel_series(closes, atr, k):
"""Regressionskanal (mid/upper/lower) als Arrays der LETZTEN k Bars fürs
Chart-Overlay — Regression über die letzten _REG_N ABGESCHLOSSENEN Bars,
linear über alle k Bars extrapoliert (volle Chart-Breite). closes = alle
Closes (letzter = offener Bar). Gibt {dir, mid[], upper[], lower[]} zurück
(jeweils Länge k, deckungsgleich mit den zurückgelieferten Bars) oder None."""
if k < 2 or len(closes) < 6:
return None
cc = closes[:-1] # nur abgeschlossene Bars (wie die Struktur)
N = min(_REG_N, len(cc))
if N < 5:
return None
seg = cc[-N:]
mx = (N - 1) / 2.0
my = sum(seg) / N
sxx = sum((x - mx) ** 2 for x in range(N))
sxy = sum((x - mx) * (seg[x] - my) for x in range(N))
b = sxy / sxx if sxx else 0.0
a = my - b * mx # Preis bei x=0 (Fensterstart)
resid = [seg[x] - (a + b * x) for x in range(N)]
up_off, lo_off = max(resid), min(resid)
x0 = len(cc) - N # cc-Index von x=0
base = len(closes) - k # closes-Index des ersten Ausgabe-Bars
mid, up, lo = [], [], []
for j in range(k):
x = (base + j) - x0 # x relativ zum Fensterstart (extrapoliert)
m = a + b * x
mid.append(round(m, 3)); up.append(round(m + up_off, 3)); lo.append(round(m + lo_off, 3))
d = "flat" if (atr and abs(b) < _SLOPE_DEAD * atr) else ("up" if b > 0 else "down")
return {"dir": d, "mid": mid, "upper": up, "lower": lo}
class MarketStructure:
def __init__(self):
self._snap: dict = {"trend": None, "swings": [], "last_swing": None,
"bos": None, "channel": None, "tf": "M30", "error": None}
self._last_refresh = 0.0
self._lock = threading.Lock()
def refresh_market(self, sym: str):
now = time.time()
if now - self._last_refresh < _REFRESH_S:
return
try:
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) < _REG_N + 5:
return
# letzte (offene) Kerze weglassen → nur abgeschlossene Struktur
highs = [float(b["high"]) for b in bars[:-1]]
lows = [float(b["low"]) for b in bars[:-1]]
closes = [float(b["close"]) for b in bars[:-1]]
times = [int(b["time"]) for b in bars[:-1]] # Broker-Zeit (MQL5-Anker)
n = len(closes)
atr = _atr(highs, lows, closes)
piv = _pivots(highs, lows, _PIVOT_K)
labels = _classify(piv)
snap = {
"trend": _trend_state(labels) if labels else "range",
"swings": [{"type": l["type"], "price": l["price"]}
for l in labels[-_MAX_SWINGS:]],
"last_swing": labels[-1]["type"] if labels else None,
"bos": _last_bos(labels, n),
"channel": _channel(closes, atr),
"channel_line": _channel_anchors(closes, times, atr),
"tf": "M30",
"error": None,
}
with self._lock:
self._snap = snap
self._last_refresh = now
except Exception as e:
with self._lock:
self._snap["error"] = str(e)[:120]
log.warning(f"MarketStructure.refresh: {e}")
def snapshot(self) -> dict:
with self._lock:
return dict(self._snap)