"""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) _MARKET_TTL_S = 300.0 # Funding/OI aendern sich stuendlich — 5 min genuegt # ⚠ EIGENER, laengerer Timeout: `/api/market` fragt die HL-API live nach ALLEN # Oel-Maerkten (4 Stueck) und braucht deutlich laenger als der Waende-Endpunkt, # der aus einer lokalen DB liest. Mit dem kurzen Waende-Timeout lief der Aufruf # immer ins Leere — und der stille `except` machte daraus ein wortloses None. _MARKET_TIMEOUT_S = 12.0 _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 self._mkt_cache: dict | None = None self._mkt_ts = 0.0 def market(self) -> dict | None: """HL-KONTEXT je Oel-Markt: Funding, Open Interest, Oracle-Premium. ⚠⚠ REINE ANZEIGE — kein Verdict-Gewicht, kein Trade-Trigger. Gemessen (`analyze_hl_funding.py`, 2026-08-06, 5.095 Funding-Stundenwerte ueber 7 Monate gegen den Pepperstone-CFD): **entueberlappt haelt KEIN Bucket** die 2-Stichproben-Regel. Der erste Lauf sah nach mehreren robusten Effekten aus — das war Autokorrelation (stuendliche Beobachtungen, 4/12/24-h-Fenster teilen sich fast ihre ganze Laenge). Deshalb steht das hier als Kontext in den Meldungen und NICHT in der Empfehlung. Quelle ist das LOKALE Dashboard (Port 8001), nicht die HL-API direkt — keine neue externe Abhaengigkeit im Trading-Bot. Eigene, laengere Drosselung (5 min): Funding aendert sich stuendlich, das braucht nicht den 5-s-Takt der Waende. Fail-safe: bei Fehlern None. """ if not self.enabled: return None now = time.time() with self._lock: if (self._mkt_cache is not None and now - self._mkt_ts < _MARKET_TTL_S): return self._mkt_cache aus = None try: url = self.url.replace("/api/walls", "/api/market") with urllib.request.urlopen(url, timeout=_MARKET_TIMEOUT_S) as r: d = json.loads(r.read().decode()) if d.get("ok"): for m in (d.get("markets") or []): # Nur der Markt, aus dem auch die Waende kommen. if m.get("market") != "xyz:CL": continue mark, orac = m.get("mark_px"), m.get("oracle_px") aus = {"funding": m.get("funding"), "open_interest": m.get("open_interest"), "mark": mark, "oracle": orac, "premium": (float(mark) - float(orac)) if (mark and orac) else None, "day_volume": m.get("day_volume"), "network": d.get("network")} break except Exception: aus = None # still — Kontext darf nie stoeren with self._lock: if aus is not None: self._mkt_cache, self._mkt_ts = aus, now return self._mkt_cache 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