diff --git a/CLAUDE.md b/CLAUDE.md index b9610c5..2a90d33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5730,6 +5730,56 @@ je anfasst, gehört diese Zelle nachgemessen, nicht die gestaffelte. dokumentierten Squeeze-Kontrollwert (+0,124/+0,292) nicht. Belastbar ist allein der **relative** Vergleich — alle Varianten laufen auf derselben Entry-Liste. +### ✅ TELEGRAM BEI GRÜNER M15-KARTE (2026-08-14, `[telegram] m15_alert`) + +User: „wenn es eine Empfehlung gibt (grün), schicke eine Telegram-Nachricht." +`engine._check_m15_alert`, aufgerufen direkt hinter `_log_m15_state` — **dieselbe +Quelle** wie Anzeige und Selbstmessung, damit die drei nicht auseinanderlaufen +können (die Divergenz-Falle, die am 12.08. schon einmal zugeschlagen hat). + +⚠⚠ **DIE ENTPRELLUNG IST DER GANZE BAU, nicht ein Detail.** Roh springt die +Karte **85× am Tag** auf grün (`m15_states`, 3 Tage nach der Hysterese vom +12.08.) — das wäre Spam und damit nach zwei Tagen stummgeschaltet. Alle Stufen +sind an den echten Daten kalibriert, nicht geraten: +| Stufe | Meldungen/Tag | +|---|---| +| nur Flanke | **85,0** | +| + Level & Richtung als Schlüssel | 23,7 | +| + 30 min Mindestabstand | 13,3 | +| **+ Fenster 8–20 Uhr (Default)** | **7,7** | +| *(Vergleich: Close-Meldung ab 50 €)* | *2,4–6,1* | +⚠ Nach 4 h (`_ALERT_NEU_S`) darf dasselbe Level erneut melden — sonst +verschweigt der Alert ein Setup, das den ganzen Tag wiederkommt. +Schalter: `m15_alert` (true) · `m15_alert_cooldown_min` (30) · +`m15_alert_hours` (8-20). Backup `oil_widget_config.ini.bak-2026-08-14-m15alert`. + +⚠⚠ **DER TEXT SAGT AUSDRÜCKLICH, DASS ES KEINE PROGNOSE IST.** Die Karte +automatisch zu handeln fällt gemessen durch (`backtest_m15_auto.py`, 13.08.: +ØR −0,162/−0,094, KI ohne Null, schlechter als die HTF-Richtung UND als ihr +eigenes Gegenteil). Die Meldung ist ein **Hinweis zum Hinsehen** für den +Menschen, dessen Auswahl-Vorteil unabhängig belegt ist (+3,20 €/Lot über 146 +Trades) — und sie schreibt das in die Nachricht. Die P(break)-Formulierung ist +**wortgleich** zu Karte und Chart (v1.35-Fix). +✅ **13 Tests** (`tests/test_m15_alert.py`), Schwerpunkt auf dem, was NICHT +gesendet werden darf. **Mutationsprobe in vier Zweige** — alle gefangen. +⚠⚠ **Die Probe hat ein echtes TESTLOCH aufgedeckt:** das Entfernen der +Flankenerkennung liess zunächst **alle** Tests grün, weil Cooldown und +4-h-Schlüssel dasselbe mit abfangen. Zwei Schutzschichten, die sich gegenseitig +maskieren — dasselbe Muster wie beim Fill-Dedup. Geschlossen durch +`test_flankenerkennung_ISOLIERT` (Cooldown aus, Level wandert je Tick). +✅ Live verifiziert: seit dem Serverstart **genau 1 Alert**, obwohl die Karte +seitdem mehrfach grün↔gelb gewechselt hat. + +**⚠⚠ NEBENBEFUND — TESTS SCHRIEBEN INS PRODUKTIONS-LOG (behoben).** Nach den +Testläufen standen **über 150** `📨 M15-Alert`-Zeilen in `oil_widget.log`, dazu +`Close-Push #4711` mit dem synthetischen Ticket aus `test_close_buchung.py`. Ich +habe daraufhin zuerst geglaubt, die Entprellung sei live defekt, und musste die +Herkunft an der Level-Folge (82.00, 82.01, …) nachweisen. In einem Projekt, +dessen Forensik am Log hängt (18-h-Copilot-Ausfall, stille Fehlbuchung), sind +erfundene Zeilen ein echtes Risiko. `tests/conftest.py` entfernt den +`FileHandler` des `oil`-Loggers jetzt für die Sitzung — **vierte Grundregel der +Suite.** Verifiziert: Log-Größe vor und nach einem Komplettlauf **identisch**. + ### ✅ S/R-AUTO-CLOSE WIEDER AN (User-Entscheidung 2026-08-11) Frage war: „sollten wir Trades bei einem S/R-Bounce automatisch schliessen?" diff --git a/core/engine.py b/core/engine.py index 0f93a0d..e911611 100644 --- a/core/engine.py +++ b/core/engine.py @@ -64,6 +64,9 @@ _ATR_TF_MAP = {"M1": mt5.TIMEFRAME_M1, "M5": mt5.TIMEFRAME_M5, "H1": mt5.TIMEFRAME_H1} # Loop-Intervalle (Sekunden) — entsprechen den *_REFRESH_MS-Werten des Widgets +# Nach dieser Zeit darf DASSELBE Level erneut melden — sonst verschweigt der +# Alert ein Setup, das den ganzen Tag wiederkommt (s. `_check_m15_alert`). +_ALERT_NEU_S = 4 * 3600 _PRICE_S = 0.5 _TREND_S = 5.0 _POS_S = 1.0 @@ -192,6 +195,24 @@ class TradingEngine: 0.0, float(tg.get("close_notify_min_eur", "50") or 0)) except (TypeError, ValueError): self._close_notify_min = 50.0 + # ── M15-Alert (User-Wunsch 2026-08-14) ──────────────────────────── + # Defaults an den ECHTEN Daten kalibriert, nicht geraten: roh 85 + # Gruen-Flanken/Tag → mit Level-Entprellung 23,7 → +30 min 13,3 → + # +Fenster 8-20 Uhr **7,7/Tag** (Close-Meldung liegt bei 2,4-6,1). + self._m15_alert = (tg.get("m15_alert", "true").strip().lower() == "true") + try: + self._m15_alert_cd = max( + 0.0, float(tg.get("m15_alert_cooldown_min", "30") or 0)) * 60.0 + except (TypeError, ValueError): + self._m15_alert_cd = 1800.0 + try: + _h = str(tg.get("m15_alert_hours", "8-20")).split("-") + self._m15_alert_hours = (int(_h[0]), int(_h[1])) + except (TypeError, ValueError, IndexError): + self._m15_alert_hours = (8, 20) + self._m15_alert_war = False # Flankenerkennung + self._m15_alert_ts = 0.0 + self._m15_alert_key = None # ── Markt / Trading / Trailing ──────────────────────────────────── self.data = MT5Data() @@ -3506,6 +3527,78 @@ class TradingEngine: except Exception as e: log.debug(f"m15_state log: {e}") + def _check_m15_alert(self, s: dict | None): + """Telegram, sobald die M15-Karte auf GRÜN mit gerichteter Lesart springt. + + User-Wunsch 2026-08-14: „wenn es eine Empfehlung gibt (grün), schicke + eine Telegram-Nachricht." + + ⚠⚠ ENTPRELLUNG IST HIER DER GANZE BAU, nicht ein Detail. Roh gemessen + (`m15_states`, 3 Tage nach der Hysterese vom 12.08.) springt die Karte + **85× am Tag** auf grün — das waere Spam, kein Signal. Zwei Stufen, + beide an den echten Daten kalibriert statt geraten: + · **Level + Richtung** als Schluessel: gruen→gelb→gruen am SELBEN + Level ist dasselbe Setup, das flackert, keine neue Gelegenheit. + 85 → 23,7/Tag. + · **Mindestabstand** `m15_alert_cooldown_min` (30) → 13,3/Tag, + und das **Stundenfenster** `m15_alert_hours` (8–20) → **7,7/Tag**. + Zum Vergleich: die Close-Benachrichtigung liefert 2,4–6,1/Tag. + ⚠ Nach `_ALERT_NEU_S` (4 h) darf dasselbe Level erneut melden — sonst + verschwiegen wir ein Setup, das den ganzen Tag wiederkommt. + + ⚠⚠ EHRLICH IM TEXT: die Karte ist gemessen KEIN Richtungssignal. Sie + automatisch zu handeln faellt durch (`backtest_m15_auto.py` 13.08.: + ØR −0,162/−0,094, KI ohne Null, schlechter als HTF-Richtung UND als das + eigene Gegenteil). Die Meldung ist deshalb ein **Hinweis zum Hinsehen** + fuer den Menschen — dessen Auswahl-Vorteil unabhaengig belegt ist + (+3,20 €/Lot ueber 146 Trades) — und nennt das im Text. + `m15_alert=false` schaltet ab. + """ + if not self._m15_alert or not s: + return + try: + if s.get("status") != "gruen" or s.get("lesart") not in ("auf", "ab"): + self._m15_alert_war = False + return + if self._m15_alert_war: # nur die FLANKE, nicht der Zustand + return + self._m15_alert_war = True + now = time.time() + h = datetime.now(_BERLIN).hour + lo, hi = self._m15_alert_hours + if not (lo <= h < hi): + return + nah = s.get("nah") or {} + key = (round(nah.get("level") or 0, 2), s.get("lesart")) + alt_ts = self._m15_alert_ts + if (now - alt_ts) < self._m15_alert_cd: + return + if key == self._m15_alert_key and (now - alt_ts) < _ALERT_NEU_S: + return + tg = self.cfg["telegram"] + if tg.get("enabled", "false").strip().lower() != "true": + return + self._m15_alert_ts, self._m15_alert_key = now, key + pb = nah.get("p_break") + lese = ("unentschieden" if pb is None or 45 <= pb <= 55 else + f"{pb} % Bruch" if pb > 55 else f"{100 - pb} % Abprall") + seite = "Widerstand" if nah.get("seite") == "res" else "Support" + erl = {"SHORT": "LONG", "LONG": "SHORT"}.get( + (s.get("bias") or {}).get("verboten")) + k = (s.get("kosten") or {}).get("ratio") + send_telegram( + f"🟢 M15 · Setup bereit\n" + f"{seite} M30 {nah.get('level')} · {nah.get('gap_atr15')}×ATR_M15\n" + f"Level {lese}" + + (f" · erlaubt wäre {erl}" if erl else " · kein Trend-Veto") + + (f" · Kosten {k}×ATR" if k is not None else "") + + "\nBedingung erfüllt — keine Richtungsprognose " + "(gemessen 48–51 % Treffer).", + tg.get("bot_token", "").strip(), tg.get("chat_id", "").strip()) + log.info(f"📨 M15-Alert {seite} {nah.get('level')} ({lese})") + except Exception as e: + log.debug(f"m15_alert: {e}") + def _log_cone_check(self, cone: dict | None): """Legt je Horizont EINEN nicht-ueberlappenden Abdeckungs-Check an. @@ -3866,6 +3959,7 @@ class TradingEngine: _m15 = self._m15_setup(market, wave_snap, _cone, self._alignment_cached(), wave_signal) self._log_m15_state(_m15, market) + self._check_m15_alert(_m15) # Telegram bei Flanke auf GRUEN self._log_cone_check(_cone) # Für den KI-Copiloten festhalten (2026-08-02) — er bekam bis dahin nur # das Wellen-Signal und sah ausgerechnet die beiden kalibrierten Größen diff --git a/tests/conftest.py b/tests/conftest.py index 5701282..61a08d2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,7 @@ damit dokumentiert, statt still zu passieren. """ import sys import types +import logging from pathlib import Path import pytest @@ -28,6 +29,33 @@ from core.engine import TradingEngine # noqa: E402 from core.history import HistoryLogger # noqa: E402 +@pytest.fixture(autouse=True, scope="session") +def _kein_produktions_log(): + """VIERTE Grundregel der Suite: Tests schreiben NICHT ins Produktions-Log. + + ⚠⚠ ANLASS (2026-08-14, real passiert): beim Bau des M15-Alerts standen nach + den Testläufen **über 150** `📨 M15-Alert`-Zeilen in `oil_widget.log` — dazu + `Close-Push #4711` mit dem synthetischen Ticket aus `test_close_buchung.py`. + Ich habe daraufhin zuerst geglaubt, die Entprellung sei live defekt, und + musste die Herkunft mühsam an der Level-Folge (82.00, 82.01, …) nachweisen. + In einem Projekt, dessen Forensik am Log hängt (der 18-h-Ausfall des + Copiloten, der stille `positions_get`-Fehlbuchung), sind erfundene Zeilen + im Produktions-Log ein echtes Risiko — nicht nur Unordnung. + + `core/logger.py` haengt einen `RotatingFileHandler` an den Logger `oil`, + sobald `core.engine` importiert wird. Hier wird er fuer die Dauer der + Sitzung entfernt und danach zurueckgegeben — die Log-AUFRUFE laufen weiter + (sie gehoeren zum getesteten Code), nur ihr Ziel entfaellt. + """ + lg = logging.getLogger("oil") + weg = [h for h in lg.handlers if isinstance(h, logging.FileHandler)] + for h in weg: + lg.removeHandler(h) + yield + for h in weg: + lg.addHandler(h) + + @pytest.fixture def db(tmp_path): """Frische, vollständig angelegte Test-Datenbank. diff --git a/tests/test_m15_alert.py b/tests/test_m15_alert.py new file mode 100644 index 0000000..5662a52 --- /dev/null +++ b/tests/test_m15_alert.py @@ -0,0 +1,181 @@ +"""M15-Alert: die ENTPRELLUNG ist der ganze Bau — also wird sie geprueft. + +ANLASS (2026-08-14, User: „wenn es eine Empfehlung gibt (gruen), schicke eine +Telegram-Nachricht"). + +⚠⚠ Roh gemessen springt die Karte **85x am Tag** auf gruen (`m15_states`, 3 Tage +nach der Hysterese vom 12.08.). Ohne Entprellung waere die Meldung Spam und +damit wertlos — genau die Sorte Funktion, die man nach zwei Tagen stummschaltet. +Die Stufen und ihre gemessene Wirkung: + Flanke allein 85,0/Tag + + Level+Richtung als Schluessel 23,7/Tag + + 30 min Mindestabstand 13,3/Tag + + Fenster 8-20 Uhr 7,7/Tag +Deshalb liegt der Schwerpunkt der Tests auf dem, was NICHT gesendet werden darf. +""" +from __future__ import annotations + +import time +from types import SimpleNamespace + +import pytest + + +class _Fake: + """Minimal-Engine mit genau den Feldern, die `_check_m15_alert` anfasst.""" + + def __init__(self, **kw): + from core.engine import TradingEngine + self._check_m15_alert = TradingEngine._check_m15_alert.__get__(self) + self._m15_alert = kw.get("an", True) + self._m15_alert_cd = kw.get("cd", 1800.0) + self._m15_alert_hours = kw.get("hours", (0, 24)) + self._m15_alert_war = False + self._m15_alert_ts = 0.0 + self._m15_alert_key = None + self.cfg = {"telegram": {"enabled": "true", "bot_token": "t", "chat_id": "c"}} + + +def _karte(status="gruen", lesart="auf", level=82.069, pb=69): + return {"status": status, "lesart": lesart, + "nah": {"seite": "res", "level": level, "gap_atr15": 0.48, "p_break": pb}, + "bias": {"verboten": "SHORT"}, "kosten": {"ratio": 0.11}} + + +@pytest.fixture +def gesendet(monkeypatch): + """Faengt jeden Telegram-Versand ab — kein Netz im Test (conftest-Regel).""" + raus = [] + monkeypatch.setattr("core.engine.send_telegram", + lambda text, *a, **k: raus.append(text)) + return raus + + +def test_gruene_flanke_sendet(gesendet): + e = _Fake() + e._check_m15_alert(_karte()) + assert len(gesendet) == 1 + assert "Setup bereit" in gesendet[0] + + +def test_zustand_sendet_nur_EINMAL(gesendet): + """⚠ Die wichtigste Zeile: gruen bleibt gruen ueber viele Ticks. Ohne + Flankenerkennung kaeme im Sekundentakt eine Nachricht.""" + e = _Fake() + for _ in range(50): + e._check_m15_alert(_karte()) + assert len(gesendet) == 1 + + +def test_flankenerkennung_ISOLIERT(gesendet): + """⚠⚠ DIESER Test existiert wegen einer Mutationsprobe (2026-08-14): das + Entfernen der Flankenerkennung liess ALLE anderen Tests gruen — weil der + Cooldown und der 4-h-Schluessel dasselbe mit abfangen. Zwei Schutzschichten, + die sich gegenseitig maskieren, sind genau das Muster, das im Projekt beim + Fill-Dedup schon dokumentiert ist. + + Isoliert wird sie so: Cooldown AUS und ein Level, das bei jedem Tick leicht + wandert (real der Normalfall — der Kurs laeuft, der naechste Pivot rutscht). + Damit greift WEDER Cooldown NOCH Schluessel; bleibt nur die Flanke. + """ + e = _Fake(cd=0.0) + for i in range(30): + e._check_m15_alert(_karte(level=82.0 + i * 0.01)) + assert len(gesendet) == 1, ( + "ohne Flankenerkennung kaeme hier eine Nachricht je Tick") + + +def test_flackern_am_selben_level_sendet_nicht_erneut(gesendet): + """gruen -> gelb -> gruen am SELBEN Level ist dasselbe Setup, das flackert. + Gemessen der Haupttreiber: 85 -> 23,7/Tag allein durch diesen Schluessel.""" + e = _Fake() + for _ in range(10): + e._check_m15_alert(_karte()) + e._check_m15_alert(_karte(status="gelb")) + assert len(gesendet) == 1 + + +def test_neues_level_sendet_trotz_cooldown_nicht(gesendet): + """⚠ Der Mindestabstand hat VORRANG vor dem neuen Level — sonst genuegt ein + um einen Cent verschobenes Level, um die Entprellung auszuhebeln.""" + e = _Fake() + e._check_m15_alert(_karte(level=82.069)) + e._check_m15_alert(_karte(status="gelb")) + e._check_m15_alert(_karte(level=83.500)) # anderes Level, aber sofort + assert len(gesendet) == 1 + + +def test_neues_level_sendet_nach_cooldown(gesendet): + e = _Fake(cd=0.0) + e._check_m15_alert(_karte(level=82.069)) + e._check_m15_alert(_karte(status="gelb")) + e._check_m15_alert(_karte(level=83.500)) + assert len(gesendet) == 2 + + +def test_gleiches_level_wiederholt_nicht_vor_vier_stunden(gesendet): + """Ohne Cooldown, aber gleicher Schluessel -> erst nach _ALERT_NEU_S wieder.""" + e = _Fake(cd=0.0) + e._check_m15_alert(_karte(level=82.069)) + e._check_m15_alert(_karte(status="gelb")) + e._check_m15_alert(_karte(level=82.069)) + assert len(gesendet) == 1 + + +def test_ausserhalb_des_stundenfensters_still(gesendet): + e = _Fake(hours=(23, 24)) # jetzt ist praktisch nie drin + if time.localtime().tm_hour == 23: + pytest.skip("Testzeit liegt zufaellig im Fenster") + e._check_m15_alert(_karte()) + assert gesendet == [] + + +def test_unklare_lesart_sendet_nicht(gesendet): + """45-55 % ist ein Muenzwurf — daraus eine Meldung zu machen waere genau der + Fehler, den die Karte an drei Stellen schon gemacht hat.""" + e = _Fake() + e._check_m15_alert(_karte(lesart="unklar")) + assert gesendet == [] + + +def test_gelb_und_rot_senden_nicht(gesendet): + e = _Fake() + e._check_m15_alert(_karte(status="gelb")) + e._check_m15_alert(_karte(status="rot")) + assert gesendet == [] + + +def test_abgeschaltet_sendet_nicht(gesendet): + e = _Fake(an=False) + e._check_m15_alert(_karte()) + assert gesendet == [] + + +def test_telegram_deaktiviert_sendet_nicht(gesendet): + e = _Fake() + e.cfg["telegram"]["enabled"] = "false" + e._check_m15_alert(_karte()) + assert gesendet == [] + + +def test_text_nennt_die_gemessene_lesart_und_warnt(gesendet): + """Die Zahl muss WORTGLEICH zur Karte und zum Chart sein (v1.35-Fix), und der + Text darf keine Prognose behaupten.""" + e = _Fake() + e._check_m15_alert(_karte(pb=33)) + t = gesendet[0] + assert "67 % Abprall" in t, "P(Abprall) wie in Karte und Chart" + assert "keine Richtungsprognose" in t + assert "erlaubt wäre LONG" in t # Veto SHORT -> erlaubt LONG + + +def test_kein_absturz_ohne_daten(gesendet): + e = _Fake() + e._check_m15_alert(None) + e._check_m15_alert({}) + e._check_m15_alert({"status": "gruen", "lesart": "auf"}) # `nah` fehlt + assert isinstance(gesendet, list) + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"]))