Files
Axel HocksandClaude Opus 5 fe7a1acb97 Kurslueckenerkennung: zwei Fehler behoben
User-Meldung "die Kurslueckenerkennung funktioniert nicht richtig". Zwei Fehler
gefunden:

1) Die groesste Luecke des Jahres wurde um 3 Cent verfehlt. Die Definition
   verlangte ein lueckenloses Vakuum zwischen den TAGESSPANNEN. Am Wochenende
   31.07.->03.08. fiel WTI von Schluss 86,33 auf Eroeffnung 79,76 = -6,57 $ -
   unsichtbar, weil Montags Hoch (81,67) das Freitags-Tief (81,64) um 0,03 $
   ueberlappte. In einem 24/5-Markt ueberlappen die Tagesspannen fast immer;
   die strenge Regel fand im ganzen Jahr nur 5 Gaps.

2) Alle Gap-Datumsangaben waren einen Tag zu frueh: int(b["time"]) minus
   _BROKER_OFFSET_S zog 3 h von einer Bar-Zeit ab, die MT5 bereits als naive
   Broker-Zeit liefert. Die gemeldete Luecke "2026-07-26" war real der 27.07.
   Am Jahreswechsel haette es zusaetzlich den year-Filter verschoben.

Fix: zwei getrennte Gap-Begriffe (kind).
- vacuum     = alte strenge Definition. NUR diese speisen zone_lines() ->
               _sr_levels -> Wellen-Konfidenz, denn nur auf ihnen wurde
               gemessen (backtest_gaps.py). Strategie-Eingang bitgenau
               unveraendert: zone_lines() liefert vorher wie nachher [88.288].
- close_open = Schluss->Eroeffnung, das was ein Mensch "Kurslucke" nennt.
               REINE ANZEIGE, ab 0,50 $, fuellt auch am selben Tag.

Live: 4 offene Luecken statt 1 (1 Vakuum + 3 Eroeffnungsluecken), darunter
endlich die -6,57-$-Wochenendluecke mit Fill bei 86,33. Frontend kennzeichnet
beide Arten mit Tooltip. v=133.

Bewusst NICHT getan: close_open in die Konfidenz einspeisen - das waere ein
ungemessener Strategie-Eingriff, und der vorhandene Befund beruht auf der
Vakuum-Definition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:19:48 +02:00

186 lines
9.1 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/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.
ZWEI Gap-Begriffe — bewusst getrennt (Fix 2026-08-03):
1. `kind="vacuum"` — **Vakuum zwischen den TAGESSPANNEN** (die strenge Definition):
UP : Low(heute) > High(gestern) → Lücke [High_gestern … Low_heute]
DOWN : High(heute) < Low(gestern) → Lücke [High_heute … Low_gestern]
Nur DIESE speisen `zone_lines()` → `_sr_levels` → Wellen-Konfidenz, denn nur auf
ihnen wurde gemessen (`backtest_gaps.py`: „der Magnet zieht NICHT" — Signale WEG
vom Gap +0,094 Ø-Edge, ZUM Gap nur +0,014). Die Definition zu ändern würde diese
Messung ungültig machen, also bleibt der Strategie-Eingang unangetastet.
2. `kind="close_open"` — **Schlusskurs → Eröffnung**, also das, was ein Mensch als
„Kurslücke" bezeichnet. REINE ANZEIGE, fließt NICHT in `zone_lines()`.
⚠ WARUM das nötig wurde (User-Meldung „die Kurslückenerkennung funktioniert nicht
richtig"): Am Wochenende 31.07.→03.08. fiel WTI von Schluss 86,33 auf Eröffnung
79,76 — **6,57 $**. Die Vakuum-Definition sah davon NICHTS, weil Montags Hoch
(81,67) das Freitags-Tief (81,64) um **3 Cent** überlappte. Ein Detektor, der die
größte Lücke des Jahres an drei Cent scheitern lässt, beantwortet die Frage nicht,
die der Nutzer stellt. In einem 24/5-Markt überlappen die Tagesspannen fast immer —
deshalb fand die strenge Regel im ganzen Jahr nur 5 Gaps.
„Gefüllt" = ein späterer Bar handelt wieder in/durch die Lücke.
"""
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, min_close_open: float = 0.50):
# `min_close_open`: ab welcher Schluss→Eröffnung-Differenz eine Lücke
# gemeldet wird. 0,50 $ filtert das normale Wochenend-Rauschen heraus und
# lässt die handelsrelevanten Sprünge übrig (real gefunden: +1,17 / +1,13 /
# +1,05 / 6,57 $ in den letzten Wochen).
self._min_co = float(min_close_open)
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:
# ⚠ DATUMS-FIX 2026-08-03: hier stand `int(b["time"]) - _BROKER_OFFSET_S`.
# MT5 liefert die Bar-Zeit als NAIVE Broker-Zeit, die bereits als Epoch
# kodiert ist — ein zusätzlicher Abzug von 3 h schiebt jeden D1-Bar auf
# den VORTAG. Real: der Bar vom 03.08. wurde als „2026-08-02" geführt,
# die gemeldete Lücke „2026-07-26" war in Wahrheit der 27.07. Betraf
# ALLE Gap-Datumsangaben (und am Jahreswechsel den `year`-Filter).
d = dt.datetime.fromtimestamp(
int(b["time"]), tz=dt.timezone.utc).date()
rows.append({"date": d, "h": float(b["high"]), "o": float(b["open"]),
"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",
"kind": "vacuum",
"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",
"kind": "vacuum",
"lo": round(cur["h"], 3), "hi": round(p["l"], 3),
"fill": round(p["l"], 3)})
else:
# Schluss → Eröffnung: die Lücke, die ein Mensch meint. Nur wenn
# KEIN Vakuum vorliegt (sonst doppelt), und erst ab `min_close_open`.
co = cur["o"] - p["c"]
if abs(co) >= self._min_co:
lo, hi = sorted((p["c"], cur["o"]))
gaps.append({"i": i, "date": cur["date"].isoformat(),
"dir": "up" if co > 0 else "down",
"kind": "close_open",
"lo": round(lo, 3), "hi": round(hi, 3),
# Füllung = zurück zum Schlusskurs des Vortags
"fill": round(p["c"], 3)})
openg = []
for g in gaps:
# ⚠ Bei `close_open` zählt der Gap-Bar SELBST mit: eine Eröffnungs-
# lücke wird oft am selben Tag wieder zugehandelt („Gap am Montag
# geschlossen"). Ein Vakuum kann sich per Konstruktion nicht selbst
# füllen (es liegt ja zwischen den Spannen), dort erst ab dem Folgetag.
later = rows[g["i"]:] if g.get("kind") == "close_open" \
else 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 `_sr_levels` → Wellen-Konfidenz.
⚠ NUR `vacuum`-Gaps (Fix 2026-08-03). Der gemessene Befund
(`backtest_gaps.py`) beruht auf genau dieser Definition; die 2026-08-03
ergänzten `close_open`-Lücken sind REINE ANZEIGE und würden den
Strategie-Eingang sonst still verändern — genau die Sorte unbemerkter
Grundlagen-Verschiebung, gegen die der Deployment-Drift-Abschnitt steht.
Wer sie einbeziehen will, misst vorher (2 Stichproben).
"""
with self._lock:
return [g["fill"] for g in self._gaps
if g.get("kind", "vacuum") == "vacuum"]
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:
g = list(self._gaps)
return {"gaps": g, "n_open": len(g),
# getrennt ausweisen, damit im UI erkennbar bleibt, welche
# Lücken die Konfidenz speisen (vacuum) und welche nur Anzeige sind
"n_vacuum": sum(1 for x in g if x.get("kind", "vacuum") == "vacuum"),
"n_close_open": sum(1 for x in g if x.get("kind") == "close_open"),
"n_total": self._n_total, "last_update": self._ts}