Files
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

248 lines
10 KiB
Python
Raw Permalink 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/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)