"""core/hl_walls.py — ECHTE Liquiditäts-Wände von Hyperliquid für den MT5-Chart. Holt vom HL-Dashboard (Port 8001, `GET /api/walls`) das größte L2-Orderbuch-Level je Seite — **basis-korrigiert auf das Pepperstone-Preisniveau** — und stellt es dem Level-Export (`engine._write_levels_file` → `sr_levels.csv` → `SR_Levels.mq5`) bereit. WARUM (User-Wunsch 2026-07-30): Alle S/R-Linien im Projekt sind aus **Pivots geschätzt** („wo war mal ein Docht"). Das hier ist die einzige Quelle mit **tatsächlich ruhenden Orders** — Pepperstone ist Market Maker und liefert kein Orderbuch, Hyperliquid schon (20 Levels je Seite). ⚠ Zwei Dinge im Blick behalten: 1. **Basis-Differenz**: HL `xyz:CL` notiert ~0,45 $ unter dem CFD (anderer Kontrakt/Contango). Die Umrechnung passiert SERVERSEITIG im HL-Projekt (Median über die letzten 60 Snapshots) — hier kommt schon `bid_mt5`/`ask_mt5` an. Fehlt die Basis (zu wenige Snapshots), wird NICHT gezeichnet. 2. **Anzeige, KEIN Signal.** Ob HL-Orderbuch-Signale auf den CFD übertragbar sind, ist eine offene Frage und wird gerade gemessen (`analyze_bookflow.py` im HL-Projekt). Bis dahin fließt hier nichts in Empfehlung/Trade ein. Fail-safe: kurzer Timeout, Ergebnis gecacht; ist der HL-Server aus, liefert `walls()` einfach None und der Chart bleibt unverändert. """ from __future__ import annotations import json import threading import time import urllib.request from core.logger import get_logger log = get_logger("hlwalls") _TIMEOUT_S = 2.0 # der MT5-Loop darf nicht am HTTP hängen _TTL_S = 10.0 # Cache — die Wände wandern langsamer als der Export-Takt (~5 s) _MAX_AGE_S = 60.0 # älter = HL-Feed steht → nicht mehr zeichnen _STALE_GRACE_S = 120.0 # nach einem Fehlschlag den letzten guten Stand so lange # weiterreichen (Blink-Fix — s. Kommentar in `walls()`) class HLWalls: def __init__(self, url: str = "http://127.0.0.1:8001/api/walls", enabled: bool = False): self.url = url self.enabled = bool(enabled) self._lock = threading.Lock() self._cache: dict | None = None self._cache_ts = 0.0 self._fail_logged = False def walls(self) -> dict | None: """{'bid','bid_sz','ask','ask_sz','basis','imb5'} auf MT5-Preisniveau — oder None (aus / HL nicht erreichbar / Basis unbekannt / Daten zu alt).""" if not self.enabled: return None now = time.time() with self._lock: if self._cache is not None and now - self._cache_ts < _TTL_S: return self._cache data = None try: with urllib.request.urlopen(self.url, timeout=_TIMEOUT_S) as r: d = json.loads(r.read().decode()) if d.get("ok") and d.get("bid_mt5") and d.get("ask_mt5"): # Feed-Alter prüfen: ein stehengebliebenes Buch darf nicht als # aktuelle Liquidität gezeichnet werden. age = now - float(d.get("ts") or 0) if age <= _MAX_AGE_S: data = {"bid": float(d["bid_mt5"]), "ask": float(d["ask_mt5"]), "bid_sz": float(d.get("bid_sz") or 0), "ask_sz": float(d.get("ask_sz") or 0), "basis": d.get("basis"), "imb5": d.get("imb5"), # Wirkrichtung je Wand (Pfeil im Chart) + größere Wand. # ⚠ Lesart „die Wand hält" — INTERPRETATION, nicht gemessen. "arrow_bid": d.get("arrow_bid") or "up", "arrow_ask": d.get("arrow_ask") or "down", "dominant": d.get("dominant"), # Liquiditäts-Trendlinie (Regression über den volumen- # gewichteten Wand-Schwerpunkt): {t1,p1,t2,p2,dir,slope_per_h} # — Zeiten sind LOKALE Epoch, der Aufrufer rechnet sie in # Broker-Zeit um (MT5-Charts brauchen Broker-Zeit). "trend": d.get("trend"), # ── Ergänzt 2026-08-01 (User: „blende den Hyperliquid- # Kurs neben den Pepperstone-Kurs ein"): der HL-MITTELKURS # auf MT5-Niveau. HL `xyz:CL` notiert wegen anderem # Kontrakt/Contango ~0,45 $ unter dem CFD — `basis` ist die # serverseitig gemessene Differenz (Median über 60 # Snapshots), `mid_mt5` also direkt mit dem Broker-Kurs # vergleichbar. Nützlich vor allem am Wochenende: HL # handelt 24/7, Pepperstone nicht. "hl_mid": (float(d["hl_mid"]) if d.get("hl_mid") is not None else None), "mid_mt5": (round(float(d["hl_mid"]) + float(d.get("basis") or 0.0), 3) if d.get("hl_mid") is not None else None), # ⚠ `basis_stale` (2026-08-02): der Broker tickt nicht mehr, # die Basis ist auf dem letzten Stand mit LEBENDEM Markt # eingefroren. Vorher rechnete die Gegenseite die Basis # blind aus den letzten 60 Snapshots — bei stehendem Broker # sog sie den kompletten HL-Wochenend-Move auf und # `hl_mid + basis` ergab wieder den EINGEFRORENEN Broker- # Kurs (real 86,41 statt 81,89, Differenz zum Broker-Bid # nur 0,081 = zirkulär). Alles, was daraus abgeleitet wird, # ist bei `basis_stale` eine SCHÄTZUNG und wird so markiert. "basis_stale": bool(d.get("basis_stale")), "mt5_still_s": d.get("mt5_still_s"), "age_s": round(age, 1)} self._fail_logged = False except Exception as e: if not self._fail_logged: # nur 1× melden, nicht jeden Tick log.info(f"HL-Wände nicht abrufbar ({e}) — Chart bleibt ohne " f"Liquiditätslinien (HL-Dashboard auf 8001 läuft nicht?)") self._fail_logged = True with self._lock: if data is not None: self._last_good = data self._last_good_ts = now elif (getattr(self, "_last_good", None) is not None and now - getattr(self, "_last_good_ts", 0.0) < _STALE_GRACE_S): # ⚠ BLINK-FIX (2026-07-30, User „die Liquidationspfeile blinken nur # manchmal auf"): ein einzelner Fehlschlag (HTTP-Timeout, HL-Server # kurz busy, Snapshot minimal zu alt) darf die Pfeile NICHT löschen. # Ohne den letzten guten Wert fehlen die LQ-Zeilen in der CSV → der # Indikator löscht beim Redraw alle Objekte und zeichnet sie nicht neu # → Pfeil weg, beim nächsten Erfolg wieder da = sichtbares Blinken. # Innerhalb der Grace wird der letzte gute Stand weitergereicht; # erst danach verschwinden die Pfeile wirklich (echter Ausfall). data = self._last_good self._cache = data self._cache_ts = now return data