""" 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)