""" core/gaps.py — GapAnalyzer ============================ Erkennt ungefüllte Kurslücken (Gaps) im Tageschart (D1) und liefert ihre Fill-Target-Levels als S/R-Kandidaten (Magnete). Gleiche Rolle wie `daily_levels` / `SRDetector`: NUR Kontext/Anzeige + Konfidenz (über `_sr_levels` → Wellen- Konfidenz → Autotrader), KEINE eigene Handelsrichtung. Gap-Definition (Vakuum zwischen Vortag und Folgetag): UP : Low(heute) > High(gestern) → Lücke [High_gestern … Low_heute], füllt bei High_gestern DOWN : High(heute) < Low(gestern) → Lücke [High_heute … Low_gestern], füllt bei Low_gestern „Gefüllt" = ein späterer Bar handelt wieder in/durch das Vakuum. """ from __future__ import annotations import threading import time import datetime as dt import MetaTrader5 as mt5 from core.mt5_utils import mt5_lock from core.logger import get_logger log = get_logger("gaps") _BROKER_OFFSET_S = 3 * 3600 # Brokerzeit (UTC+3) → ~UTC für die Datums-Zuordnung class GapAnalyzer: """Thread-sicher, fail-safe. `maybe_refresh()` drosselt den MT5-Zugriff.""" def __init__(self, refresh_s: int = 600, lookback_days: int = 400, year: int | None = None): self._lock = threading.Lock() self._gaps: list[dict] = [] # offene (ungefüllte) Gaps self._n_total = 0 self._ts = 0.0 self._next_run = 0.0 self._refresh_s = refresh_s self._lookback = lookback_days self._year = year # None = aktuelles Jahr def maybe_refresh(self, sym: str | None): """Im Loop aufrufen — holt die D1-Bars höchstens alle `refresh_s`.""" if not sym or time.time() < self._next_run: return self._next_run = time.time() + self._refresh_s self.detect(sym) def detect(self, sym: str): try: with mt5_lock(timeout=3) as got: if not got: self._next_run = time.time() + 30 # gleich nochmal versuchen return bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_D1, 0, self._lookback) if bars is None or len(bars) < 2: return year = self._year or dt.datetime.now().year rows = [] for b in bars: d = dt.datetime.fromtimestamp( int(b["time"]) - _BROKER_OFFSET_S, tz=dt.timezone.utc).date() rows.append({"date": d, "h": float(b["high"]), "l": float(b["low"]), "c": float(b["close"])}) rows = [r for r in rows if r["date"].year == year] if len(rows) < 2: return gaps = [] for i in range(1, len(rows)): p, cur = rows[i - 1], rows[i] if cur["l"] > p["h"]: gaps.append({"i": i, "date": cur["date"].isoformat(), "dir": "up", "lo": round(p["h"], 3), "hi": round(cur["l"], 3), "fill": round(p["h"], 3)}) elif cur["h"] < p["l"]: gaps.append({"i": i, "date": cur["date"].isoformat(), "dir": "down", "lo": round(cur["h"], 3), "hi": round(p["l"], 3), "fill": round(p["l"], 3)}) openg = [] for g in gaps: later = rows[g["i"] + 1:] if g["dir"] == "up": mn = min((r["l"] for r in later), default=g["hi"]) filled = mn <= g["lo"] else: mx = max((r["h"] for r in later), default=g["lo"]) filled = mx >= g["hi"] g["size"] = round(g["hi"] - g["lo"], 3) if not filled: g.pop("i", None) openg.append(g) with self._lock: self._gaps = openg self._n_total = len(gaps) self._ts = time.time() log.info(f"Gaps {year}: {len(gaps)} gesamt, {len(openg)} offen") except Exception as e: log.warning(f"GapAnalyzer.detect: {e}") def zone_lines(self) -> list[float]: """Fill-Target-Levels (für die S/R-Kandidaten / Magnete).""" with self._lock: return [g["fill"] for g in self._gaps] def nearest(self, price: float | None) -> dict: """Nächstes offenes Gap-Fill-Level über/unter dem Preis.""" if price is None: return {"above": None, "below": None} with self._lock: fills = [g["fill"] for g in self._gaps] above = sorted(f for f in fills if f > price) below = sorted((f for f in fills if f < price), reverse=True) return {"above": above[0] if above else None, "below": below[0] if below else None} def snapshot(self) -> dict: with self._lock: return {"gaps": list(self._gaps), "n_open": len(self._gaps), "n_total": self._n_total, "last_update": self._ts}