diff --git a/CLAUDE.md b/CLAUDE.md index 41c03a9..3fe2867 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3139,6 +3139,51 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: nächster Tick versucht erneut). Eigener `send_telegram`, **nicht** vom `pnl>0`-Gate (`history`) noch vom 5-Min-Cooldown betroffen. +## ⚠⚠ TELEGRAM WAR NICHT KAPUTT — ES SENDETE NUR NICHTS MEHR (2026-08-11) + +User: „ich bekomme keine Telegram-Nachrichten mehr." +✅ **Kanal nachweislich intakt:** `getMe` → `@AH_Oiltrader_bot`, `getChat` → privat +„Axel", **Testnachricht zugestellt** (message_id 1764). Im gesamten Log **keine +einzige** Fehlermeldung; der Tagesreport kam täglich (08.–11.08. je 07:3x, alle +`ok`). Letzte Nicht-Report-Nachricht **07.08. 22:49**, davor am 06.08. 19:33–19:58 +im 5-Minuten-Takt = die Close-Alarm-Sperre. +⚠⚠ **DIE URSACHE: ALLE NEUN AUSLÖSER WAREN AUS** — Auto-Squeeze (über +`runtime_state.json`, unbemerkt), Auto-Signal, S/R-Auto-Close, Flip-Close, +15-Minuten-Regel, Circuit-Breaker und die **drei** Notfall-Stop-Modi. **Jede +Abschaltung war für sich begründet und gemessen; zusammen ergaben sie eine +Stille, die niemand beschlossen hatte.** Übrig blieb genau ein Live-Trigger, der +🔔 Close-Alarm — und der braucht eine **gerichtete** Empfehlung, die es nur in +**5 %** der Zeit gibt (letzte 7 Tage: 508 von 9.976 Zeilen). +⚠ Dazu kam: der Trade-Abschluss-Push war früher bewusst entfernt worden. Deshalb +kam auch der **−87-€-SL vom 10.08. wortlos**. + +✅ **GEBAUT: Close-Benachrichtigung `engine._check_close_notify`** +(`[telegram] close_notify_min_eur = 50`, 0 = aus; Backup +`oil_widget_config.ini.bak-2026-08-11-closenotify`). Feuert bei **jedem** +geschlossenen Trade ab der Schwelle — **hängt an KEINER Automatik**, also auch +bei manuellem Close und Broker-SL. Meldet Betrag, Richtung, Lots, Ein-/Ausstieg, +**Schließgrund** und Dauer. Snapshot-Feld `close_notify_min`. +⚠ **Schwelle aus den Daten gewählt, nicht geraten:** bei ~17 Trades/Tag liefert +50 € rund **2,4–6,1 Meldungen/Tag** und erfasst **54–80 %** des bewegten Geldes. +⚠ **Eine absolute Euro-Schwelle altert mit der Positionsgröße** — Median |P&L| +über 30 Tage 14,65 €, über die letzten 7 Tage 35,80 €. Bei wachsendem Konto +kommen mehr Meldungen. Bewusst so: ein Mensch denkt in Euro. +⚠⚠ **Der Unterschied zum entfernten Push ist die SCHWELLE** (der alte feuerte bei +jedem Close), und sie liegt in der **Engine**, nicht in der DB-Schicht. +⚠ **Vorgemerkt statt sofort gesendet:** der Flat-Zustand kommt aus +`trader.snapshot()` und ist dem DB-Schreiber gelegentlich einen Tick voraus — +`exit_time IS NULL` heisst „noch nicht fertig", NICHT „kein Trade". Ohne diese +Unterscheidung meldete es einen Close **ohne P&L**. Aufgabe nach ~30 s. +✅ **7 Tests** (`tests/test_close_notify.py`, Netz abgefangen) + **Mutationsprobe +in beide kritischen Zweige**: Schwelle entfernt → `test_unter_schwelle` fällt · +Retry-Fall als „kein Trade" behandelt → `test_db_noch_nicht_fertig` fällt. + +✅ **AUTO-SQUEEZE WIEDER AN** (User-Entscheidung 2026-08-11) — über den offiziellen +Toggle `POST /api/autosqueeze`, damit ini UND `runtime_state.json` übereinstimmen +(`{"ok":true,"enabled":true}`). Der Config-Wächter meldet ihn seitdem nicht mehr. +⚠ Damit läuft die **B5-Abbruchregel** weiter (11/20 Trades) — ohne laufenden +Trader wäre sie nie abgelaufen. Latte unverändert: Verhältnis < 1,0 ODER PF < 1. + ## Mobile-UI (web/) & Alarme - Aufbau: **Tab-Navigation ganz oben** (sticky: Dashboard/**SIG Live**/**SIG Anzeige**/Logs/Statistik/News), darunter Header. diff --git a/core/engine.py b/core/engine.py index 4cae6d9..a18cae6 100644 --- a/core/engine.py +++ b/core/engine.py @@ -186,6 +186,12 @@ class TradingEngine: token = tg.get("bot_token", "").strip(), chat_id = tg.get("chat_id", "").strip(), ) + # Schwelle für die Close-Benachrichtigung (0 = aus). Siehe __init__. + try: + self._close_notify_min = max( + 0.0, float(tg.get("close_notify_min_eur", "50") or 0)) + except (TypeError, ValueError): + self._close_notify_min = 50.0 # ── Markt / Trading / Trailing ──────────────────────────────────── self.data = MT5Data() @@ -393,6 +399,22 @@ class TradingEngine: self._open_pos_dir = None # Richtung der aktuell offenen Position (LONG/SHORT) self._pos_close_ts = 0.0 # Zeit + Richtung des letzten Positions-Close (JEDE self._news_block_logged = None # Dedup für die News-Blackout-Logzeile + # ── Close-Benachrichtigung (2026-08-11, User: „ich bekomme keine Telegram- + # Nachrichten mehr"). Diagnose damals: der Kanal war intakt (Testnachricht + # zugestellt), aber ALLE NEUN Auslöser waren aus — jeder einzeln begründet, + # zusammen aber ein Nebeneffekt, den niemand beschlossen hatte. Diese + # Meldung hängt bewusst an KEINER Automatik: sie feuert bei jedem Close, + # auch bei manuellen und bei Broker-SL. Genau der −87-€-SL vom 10.08. kam + # sonst wortlos. + # ⚠ Schwelle aus den Daten gewählt, nicht geraten: bei ~17 Trades/Tag liefert + # 50 € rund 2,4–6,1 Meldungen/Tag und erfasst 54–80 % des bewegten Geldes. + # ⚠ Eine ABSOLUTE Euro-Schwelle altert mit der Positionsgrösse (Median |P&L| + # über 30 Tage 14,65 €, über die letzten 7 Tage 35,80 €) — bei wachsendem + # Konto kommen mehr Meldungen. Bewusst so: ein Mensch denkt in Euro. + # ⚠ `_close_notify_min` wird WEITER OBEN aus der ini gelesen (bei der + # Telegram-Konfiguration) — hier NICHT erneut zuweisen, das ueberschriebe + # den Wert mit 0 und die Meldung waere still tot. + self._close_notify_pending = None # [ticket, Versuche] bis `exit_time` steht # Markt-zu-Erkennung über die letzte KURSBEWEGUNG (s. `snapshot`) — die # Tick-Zeitstempel taugen dafür nicht (Abholzeit bzw. zirkulärer Offset). self._last_bid = None @@ -731,6 +753,9 @@ class TradingEngine: self.trader.refresh(self.data.symbol) self._check_circuit_breaker() # Tagesverlust-Stopp (Überleben zuerst) self._check_auto_close() + # ⚠ NACH `_check_auto_close`: erst dort wird der Flat-Übergang + # erkannt und die Benachrichtigung vorgemerkt. + self._check_close_notify() # Telegram bei jedem Close ab Schwelle self._check_adverse15() # 15-Min-Regel (nur Bot-Trades) self._check_flip_close() # Empfehlung dreht → schließen self._check_sr_close() @@ -873,6 +898,13 @@ class TradingEngine: if self._last_pos_ticket is not None and self._open_pos_dir: self._pos_close_ts = time.time() self._pos_close_dir = self._open_pos_dir + # ⚠ Close-Benachrichtigung nur VORMERKEN, nicht hier senden: der + # Flat-Zustand kommt aus `trader.snapshot()` und kann dem + # DB-Schreiber um einen Tick voraus sein. `_check_close_notify` + # wartet, bis `exit_time` steht — sonst meldete es einen Close + # ohne P&L. + if self._close_notify_min > 0: + self._close_notify_pending = [self._last_pos_ticket, 0] self._open_pos_dir = None self._last_pos_ticket = None self._emergency_loss = None @@ -4077,6 +4109,10 @@ class TradingEngine: "sr_close_min_gain": self._sr_close_min_gain, "sr_close_pbreak": self._sr_close_pbreak, "sr_close_count": self._sr_close_count, + # Schwelle der Close-Benachrichtigung — sichtbar, damit belegbar ist, + # dass sie im LAUFENDEN Prozess angekommen ist. „Erfolg melden, wo + # nichts geprueft wurde" ist im Projekt bereits viermal passiert. + "close_notify_min": self._close_notify_min, "auto_squeeze": self._auto_squeeze, # Nacht-Fenster mitliefern: nach `restart_server.bat` ist ein NEUES # Snapshot-Feld der einzige verlässliche Beleg, dass wirklich der neue @@ -4281,6 +4317,64 @@ class TradingEngine: except Exception: return None + def _check_close_notify(self): + """Telegram bei jedem geschlossenen Trade ab `close_notify_min_eur`. + + ⚠⚠ WARUM ES DAS GIBT (2026-08-11): der Kanal war nachweislich intakt + (Testnachricht zugestellt), aber ALLE NEUN sendenden Auslöser waren aus — + Auto-Squeeze, Auto-Signal, S/R-Close, Flip-Close, 15-Minuten-Regel, + Circuit-Breaker und die drei Notfall-Stop-Modi. Jede Abschaltung war für + sich begründet; zusammen ergaben sie eine Stille, die niemand beschlossen + hatte. Diese Meldung hängt deshalb bewusst an **keiner** Automatik: sie + feuert auch bei manuellem Close und bei Broker-SL. + ⚠ Der Trade-Abschluss-Push war früher in `history.log_trade_close` und wurde + als Spam entfernt (jeder Close, ohne Schwelle). Der Unterschied ist die + **Schwelle** — und sie steckt hier in der Engine, nicht in der DB-Schicht. + """ + p = self._close_notify_pending + if not p: + return + ticket, versuche = p + tr = self.history.trade_by_ticket(ticket) + # ⚠ `exit_time IS NULL` heisst „DB-Schreiber noch nicht durch", nicht + # „kein Trade". Der Flat-Zustand kommt aus `trader.snapshot()` und ist + # dem Schreiber gelegentlich einen Tick voraus. + if not tr or tr.get("exit_time") is None or tr.get("pnl") is None: + p[1] = versuche + 1 + if versuche >= 30: # ~30 s — dann war es kein Bot-Trade + self._close_notify_pending = None + return + self._close_notify_pending = None + pnl = float(tr["pnl"] or 0.0) + if abs(pnl) < self._close_notify_min: + return + tg = self.cfg["telegram"] + if tg.get("enabled", "false").strip().lower() != "true": + return + try: + dauer = "" + if tr.get("entry_time"): + mins = (int(tr["exit_time"]) - int(tr["entry_time"])) / 60.0 + dauer = (f"{mins/60:.1f} h" if mins >= 90 else f"{mins:.0f} min") + grund = {"sl": "Broker-SL", "manual": "manuell", + "sr_close": "S/R-Auto-Close", "emergency": "Notfall-Stop", + "timestop": "Time-Stop", "adverse15": "15-Minuten-Regel", + "flip_close": "Signal-Flip", + "circuit_breaker": "Circuit Breaker", + "squeeze_reverse": "Squeeze-Reverse"}.get( + tr.get("closed_by"), tr.get("closed_by") or "?") + icon = "🟢" if pnl > 0 else "🔴" + send_telegram( + f"{icon} Trade geschlossen {pnl:+.2f} €\n" + f"{tr.get('symbol') or ''} {tr.get('direction') or ''} " + f"{tr.get('lots')} Lots · {tr.get('entry_price')} → " + f"{tr.get('exit_price')}\n" + f"{grund} · {dauer} · Setup {tr.get('setup') or '—'}", + tg.get("bot_token", "").strip(), tg.get("chat_id", "").strip()) + log.info(f"📨 Close-Push #{ticket} {pnl:+.2f} € ({grund})") + except Exception as e: + log.debug(f"close_notify: {e}") + def _m15_setup(self, market, wave_snap, cone, alignment, wave_signal=None) -> dict | None: """SETUP-BEREITSCHAFT auf M15 — der Ersatz für die entfernte Gesamtempfehlung. diff --git a/core/history.py b/core/history.py index cb11aba..7b56d9c 100644 --- a/core/history.py +++ b/core/history.py @@ -407,6 +407,22 @@ class HistoryLogger: # alert „🔔 CLOSE-Signal") sowie bei Notfall-/Gewinn-Auto-Close. Die terse # „Sym +X"-Bestätigung bei jedem Close ist entfernt. + def trade_by_ticket(self, ticket: int) -> dict | None: + """Einen Trade komplett lesen — für die Close-Benachrichtigung (2026-08-11). + + ⚠ Der Aufrufer muss `exit_time` prüfen: die Engine bemerkt den Flat-Zustand + über `trader.snapshot()` und kann dem DB-Schreiber um einen Tick voraus sein. + Ein Datensatz mit `exit_time IS NULL` heisst „noch nicht fertig", NICHT + „kein Trade" — wer das verwechselt, meldet einen Close ohne P&L. + """ + try: + with self._lock, self._connect() as conn: + row = conn.execute( + "SELECT * FROM trades WHERE ticket = ?", (int(ticket),)).fetchone() + return dict(row) if row else None + except Exception: + return None # fail-open wie der Rest dieser Schicht + def log_ai(self, *, sentiment: str, confidence: int, summary: str, drivers: list, cost_estimate: float | None, diff --git a/tests/test_close_notify.py b/tests/test_close_notify.py new file mode 100644 index 0000000..7b52a5b --- /dev/null +++ b/tests/test_close_notify.py @@ -0,0 +1,121 @@ +"""Close-Benachrichtigung: feuert sie genau dann, wenn sie soll? + +ANLASS (2026-08-11, User: „ich bekomme keine Telegram-Nachrichten mehr"). Der +Kanal war intakt — eine Testnachricht wurde zugestellt —, aber ALLE NEUN +sendenden Ausloeser waren aus. Jede Abschaltung war fuer sich begruendet; +zusammen ergaben sie eine Stille, die niemand beschlossen hatte. + +⚠ Der frueher entfernte Trade-Abschluss-Push scheiterte an fehlender SCHWELLE +(jeder Close, ~17/Tag). Genau die wird hier geprueft — und der Renner-Fall, in +dem die Engine dem DB-Schreiber einen Tick voraus ist. +""" +from __future__ import annotations + +import pytest + +import core.engine as eng +from core.engine import TradingEngine + + +class _Hist: + def __init__(self, trade): + self.trade = trade + self.aufrufe = 0 + + def trade_by_ticket(self, ticket): + self.aufrufe += 1 + return self.trade + + +class _Stub: + """Minimal-Engine — nur was `_check_close_notify` wirklich liest.""" + + _check_close_notify = TradingEngine._check_close_notify + + def __init__(self, trade, min_eur=50.0, an=True): + self.history = _Hist(trade) + self._close_notify_min = min_eur + self._close_notify_pending = [4711, 0] + self.cfg = {"telegram": {"enabled": "true" if an else "false", + "bot_token": "T", "chat_id": "C"}} + + +def _trade(pnl=-87.03, fertig=True): + return {"ticket": 4711, "symbol": "SpotCrude", "direction": "BUY", + "lots": 1.77, "entry_price": 79.065, "exit_price": 78.497, + "entry_time": 1000, "exit_time": 7180 if fertig else None, + "pnl": pnl if fertig else None, "closed_by": "sl", "setup": "WAVE"} + + +@pytest.fixture(autouse=True) +def _abfangen(monkeypatch): + """`send_telegram` abfangen — im Test geht NICHTS ins Netz.""" + gesendet = [] + monkeypatch.setattr(eng, "send_telegram", + lambda msg, tok, cid: gesendet.append(msg)) + return gesendet + + +def test_ueber_schwelle_wird_gesendet(_abfangen): + """Der reale −87,03-€-SL-Trade vom 10.08. — der kam bisher wortlos.""" + s = _Stub(_trade(-87.03)) + s._check_close_notify() + assert len(_abfangen) == 1 + t = _abfangen[0] + assert "-87.03" in t and "Broker-SL" in t and "🔴" in t + assert s._close_notify_pending is None + + +def test_unter_schwelle_bleibt_still(_abfangen): + """Genau der Grund, warum der alte Push als Spam entfernt wurde.""" + s = _Stub(_trade(12.40)) + s._check_close_notify() + assert _abfangen == [] + assert s._close_notify_pending is None, "Vormerkung trotzdem aufraeumen" + + +def test_gewinn_bekommt_gruenes_icon(_abfangen): + s = _Stub(_trade(+229.41)) + s._check_close_notify() + assert "🟢" in _abfangen[0] and "+229.41" in _abfangen[0] + + +def test_db_noch_nicht_fertig_wird_erneut_versucht(_abfangen): + """⚠ Der gefaehrlichste Fall: der Flat-Zustand kommt aus `trader.snapshot()` + und ist dem DB-Schreiber gelegentlich einen Tick voraus. `exit_time IS NULL` + heisst „noch nicht fertig", NICHT „kein Trade" — wer das verwechselt, meldet + einen Close ohne P&L.""" + s = _Stub(_trade(fertig=False)) + s._check_close_notify() + assert _abfangen == [] + assert s._close_notify_pending == [4711, 1], "muss vorgemerkt BLEIBEN" + # naechster Tick: DB ist durch + s.history.trade = _trade(-87.03) + s._check_close_notify() + assert len(_abfangen) == 1 and s._close_notify_pending is None + + +def test_gibt_nach_30_versuchen_auf(_abfangen): + """Ein Fremd-Trade ohne DB-Zeile darf nicht ewig gepollt werden.""" + s = _Stub(None) + for _ in range(31): + s._check_close_notify() + assert _abfangen == [] + assert s._close_notify_pending is None + + +def test_telegram_aus_sendet_nicht(_abfangen): + s = _Stub(_trade(-200.0), an=False) + s._check_close_notify() + assert _abfangen == [] + + +def test_ohne_vormerkung_passiert_nichts(_abfangen): + s = _Stub(_trade(-200.0)) + s._close_notify_pending = None + s._check_close_notify() + assert _abfangen == [] and s.history.aufrufe == 0, "keine DB-Last ohne Anlass" + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/web/index.html b/web/index.html index 78597d5..fad7205 100644 --- a/web/index.html +++ b/web/index.html @@ -6,7 +6,7 @@