diff --git a/CLAUDE.md b/CLAUDE.md index 57fc749..26feee1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5154,6 +5154,72 @@ Mit **5 Szenarien getestet** (alles passt · Bias uneinig · Kosten teuer · Lev zu weit · Kaltstart ohne ATR_M15 → kein Absturz), live verifiziert über `deploy.py --feld m15_setup`. +### ⚠⚠ GATE ④ ZEIGTE DAS ZIEL IN DER VERBOTENEN RICHTUNG — behoben 2026-08-10 + +**User-Fund:** „Kurs ist ca. 79 und Ziel ist 78,7 bei Empfehlung LONG — ein +Widerspruch?" **Ja, und zwar ein echter.** Live nachgestellt: +| | | +|---|---| +| Kurs (bid) | **78,918** | +| ② Bias | `verboten: SHORT` → erlaubt ist **LONG** | +| ④ Ziel | **78,768** — 0,15 $ **unter** dem Kurs, `von: 79,033` | + +Gelesen heisst das „am Widerstand einsteigen, Support anlaufen" = ein **SHORT**. +⚠ **Die Arithmetik war korrekt — nur für die verbotene Richtung.** Ursache war +eine Zeile: +```python +fern = max(zonen, key=lambda x: x["gap_atr15"]) # rein geometrisch +``` +Das Ziel war schlicht das **entferntere** der beiden Level, ohne jede +Richtungsprüfung. + +⚠⚠ **Der strukturelle Grund ist der wichtigere Teil: zwei unvereinbare +Handelskonzepte in einer Kette.** Gate ④ unterstellte stillschweigend einen +**Fade-Trade** (Level anlaufen, Gegenlevel als Ziel = Mean Reversion), Gate ② ist +aber ein **Trendfilter**. Damit ist es **derselbe Fehler, wegen dem die +Gesamtempfehlung entfernt wurde**: aus einem **VERBOT** wird über die Hintertür +wieder eine **RICHTUNG** — hier nicht über verrechnete Stimmen, sondern über die +Geometrie. Und es ist der zweite Fall in dieser Karte (der erste war der +Richtungs-Bias, korrigiert 08.08. nach 13 LONG-Episoden an einem fallenden Tag). +**Ein Verbot lässt sich offenbar auf mehr Wegen in eine Richtung verwandeln, als +man beim Bauen sieht.** + +✅ **Fix — die gemessene Population ist der Maßstab.** `backtest_m15_gates.py` +misst: Einstieg am **nächstgelegenen** Level, Richtung = die vom HTF **erlaubte**; +der Exit ist der kanonische Trailing-Exit — **ein Level-Ziel war NIE gemessen** +(④ ist ausdrücklich „reine Arithmetik"). Jetzt gilt: +| Lage (LONG erlaubt) | Anzeige | +|---|---| +| nächstes Level = **Support** → Abpraller | Ziel = Widerstand darüber ▲ | +| nächstes Level = **Widerstand** → Ausbruch | **kein Ziel** + Grund | +| **kein Veto** (M30 flach) | **kein Ziel** — ein einzelnes wäre wieder Richtung | +⚠ **„Kein Ziel" ist ein eigener Zustand, keine Lücke** — die Karte nennt den +Grund, statt eine Zahl zu erfinden. Beim Ausbruchs-Einstieg ist das nächste Ziel +schlicht **nicht berechenbar**: `_draw_levels` liefert bewusst nur die nächste +Linie je Seite (Entscheidung 14.07., sonst tauchen beim Kurslauf laufend Linien +auf/ab). +⚠ Die **Richtung steht jetzt IM Text** (`④ Ziel ▲ 79,033 … Einstieg 78,768`). +Ein Ziel ohne genannte Richtung lässt sich still wieder verdrehen — mit genannter +nicht. Neue Felder: `ziel.richtung`, `ziel_grund`. + +✅ **Mutationsprobe bestanden** (ein grüner Test belegt sonst nichts): mit der +alten Zeile zurückgebaut fallen **4 von 6** Tests, und die Fehlermeldung +reproduziert den Fund wörtlich (`{'level': 78.768, … 'von': 79.033}`). +⚠ Die zwei übrigen bleiben **korrekterweise** grün — dort liegt das entferntere +Level tatsächlich in der erlaubten Richtung, alt und neu stimmen überein. Ein +Test, der auch die richtigen Fälle rot färbt, wäre zu eng gefasst. +`tests/test_m15_ziel.py` (6 Tests) prüft die Invariante **„ein ausgewiesenes Ziel +liegt IMMER in der erlaubten Richtung"** über 32 Konstellationen × 2 Richtungen. +✅ **Live beide Zweige belegt**, weil der Kurs während der Prüfung lief: bei +bid 78,918 (nächstes = Widerstand) → kein Ziel + „Ausbruch"; bei bid 78,871 +(nächstes = Support) → Ziel 79,033 ▲, Einstieg 78,768. + +⚠ **Nebenbei behoben: `deploy.py --feld` konnte nur die oberste Snapshot-Ebene** +(`a.feld in snap`). Für `m15_setup.ziel_grund` war der Neuheits-Beweis damit gar +nicht führbar — und Schritt 4 ist der EINZIGE belastbare Nachweis, dass neuer +Python-Code läuft. Jetzt mit **Punktpfaden**; ein Feld mit Wert `None` zählt als +vorhanden (geprüft wird der Schlüssel, nicht der Inhalt). + ## ⚠⚠ EXIT-ABSTAND AUS M15-ATR = FÄLLT DURCH (2026-08-08) Zweiter Teil der M15-Idee: M5-Einstieg behalten, nur den EXIT gröber führen. diff --git a/core/engine.py b/core/engine.py index 30c8712..fdaa44d 100644 --- a/core/engine.py +++ b/core/engine.py @@ -4368,27 +4368,65 @@ class TradingEngine: for x in b80] or None # ── 4b) ZIEL-ERREICHBARKEIT — reine GEOMETRIE auf dem Kegel. - # Ziel eines Trades am nahen Level ist das GEGENÜBERLIEGENDE. - # Gefragt wird nur: passt dieser Abstand noch in das 80-%-Band - # des jeweiligen Horizonts? ⚠ KEINE Bruch-Annahme — die im - # Vorschlag mitgedachte „je länger, desto eher bricht es"-Regel - # ist gemessen NICHT robust (Touch-Zahl: umgekehrtes U auf 4 + # Gefragt wird nur: passt der Abstand noch in das 80-%-Band des + # jeweiligen Horizonts? ⚠ KEINE Bruch-Annahme — die im Vorschlag + # mitgedachte „je länger, desto eher bricht es"-Regel ist + # gemessen NICHT robust (Touch-Zahl: umgekehrtes U auf 4 # Live-Tagen, im Backtest +0,010 AUC und nach dem Nachtrainieren # gar nichts mehr). Hier steckt deshalb nur Arithmetik. - if len(zonen) == 2 and bid and b80: + # + # ⚠⚠ FEHLER BEHOBEN 2026-08-10 (User-Fund „Kurs 79, Ziel 78,7 — bei + # LONG?"). Der erste Entwurf nahm als Ziel schlicht das ENTFERNTERE + # der beiden Level (`max` nach gap) — also **rein geometrisch, ohne + # die erlaubte Richtung zu prüfen**. Real zeigte die Karte damit + # `verboten=SHORT` UND ein Ziel 0,15 $ UNTER dem Kurs: die Arithmetik + # war korrekt, aber für die VERBOTENE Richtung. + # Strukturell steckte dahinter die stillschweigende Annahme eines + # **Fade-Trades** (am Level einsteigen, Gegenlevel als Ziel = Mean + # Reversion), während Gate ② ein **Trendfilter** ist. Zwei + # unvereinbare Handelskonzepte in einer Kette — und damit derselbe + # Fehler, wegen dem die Gesamtempfehlung entfernt wurde: aus einem + # VERBOT wird über die Hintertür wieder eine RICHTUNG. + # + # Jetzt gilt die gemessene Population aus `backtest_m15_gates.py`: + # Einstieg am **nächstgelegenen** Level, Richtung = die vom HTF + # erlaubte. Ein Ziel wird nur ausgewiesen, wenn es JENSEITS dieses + # Einstiegs in der erlaubten Richtung liegt. + # ⚠ Ohne Veto (M30 flach) gibt es KEIN Ziel — ein einzelnes Ziel + # zu zeigen wäre wieder eine Richtungsaussage. + # ⚠ `_draw_levels` liefert bewusst nur die NÄCHSTE Linie je Seite. + # Beim Ausbruchs-Einstieg (nächstes Level == Ziel-Seite) ist das + # nächste Ziel damit schlicht NICHT berechenbar — dann sagt die + # Karte das, statt eine Zahl zu erfinden. + out["ziel"] = None + d_ok = (1 if verboten == "SHORT" else + -1 if verboten == "LONG" else 0) + if len(zonen) == 2 and bid and b80 and d_ok: nah = min(zonen, key=lambda x: x["gap_atr15"]) - fern = max(zonen, key=lambda x: x["gap_atr15"]) - strecke = abs(fern["level"] - bid) - out["ziel"] = { - "level": fern["level"], "strecke": round(strecke, 3), - "horizonte": [{"minutes": x.get("minutes"), - "drin": bool(x.get("lo") is not None - and x.get("hi") is not None - and x["lo"] <= fern["level"] <= x["hi"])} - for x in b80], - "von": nah["level"]} + kand = next((z for z in zonen + if z["seite"] == ("res" if d_ok > 0 else "sup")), None) + if kand is None: + grund = "kein Level in der erlaubten Richtung" + elif (kand["level"] - nah["level"]) * d_ok > 0: + strecke = abs(kand["level"] - bid) + out["ziel"] = { + "level": kand["level"], "strecke": round(strecke, 3), + "horizonte": [{"minutes": x.get("minutes"), + "drin": bool(x.get("lo") is not None + and x.get("hi") is not None + and x["lo"] <= kand["level"] <= x["hi"])} + for x in b80], + "von": nah["level"], + "richtung": "LONG" if d_ok > 0 else "SHORT"} + grund = None + else: + grund = ("Ausbruch — Einstieg am " + + ("Widerstand" if nah["seite"] == "res" else "Support") + + f" {nah['level']:.3f}, nächstes Ziel dahinter nicht berechenbar") + out["ziel_grund"] = grund else: - out["ziel"] = None + out["ziel_grund"] = (None if d_ok else + "kein Veto — Richtung offen, kein Ziel bestimmbar") # ── 4c) VOLA-REGIME als Slippage-HINWEIS (kein Gate, s. Docstring) out["vola"] = self._vola_regime(atr15) diff --git a/tests/test_m15_ziel.py b/tests/test_m15_ziel.py new file mode 100644 index 0000000..868346f --- /dev/null +++ b/tests/test_m15_ziel.py @@ -0,0 +1,125 @@ +"""Gate ④ der M15-Karte: das Ziel muss in der ERLAUBTEN Richtung liegen. + +ANLASS (2026-08-10, User-Fund): die Karte zeigte `verboten=SHORT` (also LONG +erlaubt) UND gleichzeitig ein Ziel 0,15 $ UNTER dem Kurs. Die Arithmetik war +korrekt — aber fuer die verbotene Richtung. Ursache: das Ziel war schlicht das +ENTFERNTERE der beiden Level (`max` nach gap), ohne Richtungspruefung. + +⚠ Die Tests pruefen BEIDE Richtungen. Ein Test, der nur zeigt „im Fehlerfall +kommt kein Ziel", belegt nicht, dass der korrekte Fall noch funktioniert — +genau daran ist am 07.08. die Neustart-Sperre fast gescheitert (Negativ-Test +gruen, Positiv-Test rot). +""" +from __future__ import annotations + +import pytest + +from core.engine import TradingEngine + + +class _Stub: + """Minimal-Engine: nur die beiden Methoden, die `_m15_setup` wirklich ruft.""" + + def __init__(self, res, sup): + self._res, self._sup = res, sup + + def _draw_levels(self): + return {"res": [self._res] if self._res else [], + "sup": [self._sup] if self._sup else []} + + def _vola_regime(self, atr15): + return None + + +def _setup(bid, res, sup, htf): + """`_m15_setup` mit Stub-Engine aufrufen (die Methode ist eine reine Funktion).""" + ws = {"tf_atr": {"M15": 0.25}, "htf_trend": htf, "h1_trend": htf, + "pb_feats": {"atr": 0.2, "ema_diff": 0.05, "mom6": 0.1, "mom3": 0.1}} + cone = {"levels": [{"band": 80, "minutes": m, "lo": bid - 1.0, "hi": bid + 1.0} + for m in (30, 60, 120)]} + return TradingEngine._m15_setup( + _Stub(res, sup), {"bid": bid, "ask": bid + 0.03}, ws, cone, None) + + +# ── Der reale Fehlerfall vom 10.08. ────────────────────────────────────── +def test_kein_ziel_gegen_das_veto(): + """LONG erlaubt, naechstes Level ist der Widerstand → Ausbruch, kein Ziel. + + Exakt die Live-Zahlen: bid 78,918 · res 79,033 (0,47xATR) · sup 78,768 + (0,61xATR). Vorher stand hier `ziel=78.768` — 0,15 $ UNTER dem Kurs, bei + verbotenem SHORT.""" + s = _setup(78.918, 79.033, 78.768, htf=1) + assert s["bias"]["verboten"] == "SHORT" # LONG ist erlaubt + assert s["ziel"] is None, "Ziel zeigte in die verbotene Richtung" + assert "Ausbruch" in (s["ziel_grund"] or "") + + +def test_ziel_niemals_auf_der_verbotenen_seite(): + """Die eigentliche Invariante — ueber viele Konstellationen geprueft. + + Wird ein Ziel ausgewiesen, MUSS es in der erlaubten Richtung vom Kurs + liegen. Das ist die Aussage, die der Fehler verletzt hat.""" + for htf, verboten in ((1, "SHORT"), (-1, "LONG")): + for res_d in (0.05, 0.15, 0.30, 0.60): + for sup_d in (0.05, 0.15, 0.30, 0.60): + bid = 78.918 + s = _setup(bid, bid + res_d, bid - sup_d, htf=htf) + assert s["bias"]["verboten"] == verboten + z = s["ziel"] + if z is None: + continue + if verboten == "SHORT": # LONG erlaubt → Ziel OBEN + assert z["level"] > bid, f"Ziel {z['level']} unter Kurs {bid}" + assert z["richtung"] == "LONG" + else: # SHORT erlaubt → Ziel UNTEN + assert z["level"] < bid, f"Ziel {z['level']} ueber Kurs {bid}" + assert z["richtung"] == "SHORT" + + +# ── Der korrekte Fall MUSS erhalten bleiben ────────────────────────────── +def test_fade_am_widerstand_bleibt_wenn_short_erlaubt(): + """M30 ab → SHORT erlaubt, naechstes Level ist der Widerstand → Ziel Support. + + Das ist genau die Anzeige, die vorher zu sehen war — sie war nur unter dem + FALSCHEN Veto zu sehen. Sie darf nicht mit weggefixt werden.""" + s = _setup(78.918, 79.033, 78.768, htf=-1) + assert s["bias"]["verboten"] == "LONG" + assert s["ziel"] is not None + assert s["ziel"]["level"] == 78.768 + assert s["ziel"]["von"] == 79.033 + assert s["ziel"]["richtung"] == "SHORT" + + +def test_bounce_am_support_mit_long(): + """Kurs dicht ueber dem Support, LONG erlaubt → Einstieg Support, Ziel Widerstand.""" + s = _setup(78.80, 79.30, 78.77, htf=1) # sup 0,03 nah, res 0,50 fern + assert s["ziel"] is not None + assert s["ziel"]["von"] == 78.77 # Einstieg = naechstes Level + assert s["ziel"]["level"] == 79.30 # Ziel = oben + assert s["ziel"]["richtung"] == "LONG" + + +def test_ohne_veto_kein_ziel(): + """M30 flach → keine erlaubte Richtung → ein einzelnes Ziel waere wieder + eine Richtungsaussage.""" + s = _setup(78.918, 79.033, 78.768, htf=0) + assert s["bias"]["verboten"] is None + assert s["ziel"] is None + assert "kein Veto" in (s["ziel_grund"] or "") + + +def test_kegel_pruefung_bleibt_wirksam(): + """Liegt das Ziel ausserhalb des 80-%-Bandes, muessen alle Horizonte + `drin=False` melden — die Erreichbarkeits-Aussage selbst bleibt intakt.""" + ws = {"tf_atr": {"M15": 0.25}, "htf_trend": 1, "h1_trend": 1, + "pb_feats": {"atr": 0.2, "ema_diff": 0.05, "mom6": 0.1, "mom3": 0.1}} + cone = {"levels": [{"band": 80, "minutes": m, "lo": 78.79, "hi": 78.81} + for m in (30, 60, 120)]} + s = TradingEngine._m15_setup( + _Stub(79.30, 78.77), {"bid": 78.80, "ask": 78.83}, ws, cone, None) + assert s["ziel"] is not None + assert all(h["drin"] is False for h in s["ziel"]["horizonte"]) + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-v"])) diff --git a/tools/deploy.py b/tools/deploy.py index efb7485..b48aa19 100644 --- a/tools/deploy.py +++ b/tools/deploy.py @@ -168,8 +168,24 @@ def main() -> int: schritt(3, f"Port {p}: ⚠ {len(ids)} Instanzen {ids}") # ── 4) beweist ein neues Feld, dass der PYTHON-Code neu ist? ───────── + # ⚠ PUNKTPFADE seit 2026-08-10 (`m15_setup.ziel_grund`). Vorher ging nur die + # oberste Ebene (`a.feld in snap`) — die meisten neuen Felder liegen aber + # verschachtelt, und fuer die war der Neuheits-Beweis damit gar nicht + # fuehrbar. Genau das ist Schritt 4 aber: der EINZIGE belastbare Nachweis, + # dass wirklich neuer Python-Code laeuft (statische Dateien beweisen + # nichts — `restart_server.bat` hat zweimal still nicht neu gestartet). + # ⚠ Ein Feld mit Wert `None` zaehlt als VORHANDEN — geprueft wird die + # Existenz des Schluessels, nicht sein Inhalt. Ein neues Feld ist beim + # ersten Snapshot oft noch leer. if a.feld: - if a.feld in snap: + knoten, pfad_ok = snap, True + for teil in a.feld.split("."): + if isinstance(knoten, dict) and teil in knoten: + knoten = knoten[teil] + else: + pfad_ok = False + break + if pfad_ok: schritt(4, f"Feld `{a.feld}` vorhanden → Python-Code ist neu ✅") else: fehler.append(f"Feld `{a.feld}` fehlt im Snapshot") diff --git a/web/app.js b/web/app.js index aacb91e..e7df4bd 100644 --- a/web/app.js +++ b/web/app.js @@ -224,25 +224,43 @@ function renderM15(s) { + "Quote ist also eher noch etwas optimistisch. 45–55 % bleibt ohne Richtung."; // ── Reichweite (Kegel) — sagt WIE WEIT, nicht wohin ────────────────── - // ⚠ REINE GEOMETRIE: passt das Gegenlevel (= das Ziel) noch in das 80-%-Band - // des jeweiligen Horizonts? Keine Bruch-Annahme — die wäre nicht belegt. + // ⚠ REINE GEOMETRIE: passt das Ziel noch in das 80-%-Band des jeweiligen + // Horizonts? Keine Bruch-Annahme — die wäre nicht belegt. + // ⚠⚠ Die Richtung steht seit dem Fix vom 2026-08-10 MIT im Text. Vorher war + // das Ziel rein geometrisch das entferntere Level und stand real GEGEN das + // Veto aus ② („verboten SHORT" + Ziel 0,15 $ unter dem Kurs). Ein Ziel ohne + // genannte Richtung lässt sich still wieder verdrehen — mit genannter nicht. const zi = s.ziel; const r = s.reichweite || []; + const spanne = r.length + ? " · Spanne " + r.map(x => `${x.minutes}′ ${f(x.lo, 2)}–${f(x.hi, 2)}`).join(" · ") + : ""; + const rEl = $("m15-reich"); if (zi && zi.horizonte && zi.horizonte.length) { const drin = zi.horizonte.filter(x => x.drin).map(x => x.minutes); - const el = $("m15-reich"); - el.className = "m15-item " + (drin.length ? "ok" : "warn"); - el.textContent = drin.length - ? `④ Ziel ${f(zi.level, 3)} erreichbar ab ${Math.min(...drin)}′ ` - + `(${zi.strecke} $ vom Kurs)` - : `④ Ziel ${f(zi.level, 3)} liegt AUSSERHALB des 120′-Kegels ` + const pfeil = zi.richtung === "LONG" ? "▲" : "▼"; + rEl.className = "m15-item " + (drin.length ? "ok" : "warn"); + rEl.textContent = drin.length + ? `④ Ziel ${pfeil} ${f(zi.level, 3)} erreichbar ab ${Math.min(...drin)}′ ` + + `(${zi.strecke} $ vom Kurs, Einstieg ${f(zi.von, 3)})` + : `④ Ziel ${pfeil} ${f(zi.level, 3)} liegt AUSSERHALB des 120′-Kegels ` + `(${zi.strecke} $) — CRV zeitlich nicht gedeckt`; + } else if (s.ziel_grund) { + // Kein berechenbares Ziel ist ein EIGENER Zustand, keine Lücke — + // deshalb wird der Grund genannt statt eine Zahl erfunden. + rEl.className = "m15-item"; + rEl.textContent = `④ kein Ziel — ${s.ziel_grund}${spanne}`; } else { - $("m15-reich").className = "m15-item"; - $("m15-reich").textContent = r.length + rEl.className = "m15-item"; + rEl.textContent = r.length ? "④ erwartete Spanne · " + r.map(x => `${x.minutes}′ ${f(x.lo, 2)}–${f(x.hi, 2)}`).join(" · ") : "④ erwartete Spanne —"; } + rEl.title = "Reine Geometrie auf dem Kegel: liegt das Ziel im 80-%-Band des " + + "Horizonts? Das Ziel wird NUR in der von ② erlaubten Richtung gebildet — " + + "ein rein geometrisches Ziel zeigte am 10.08. auf die verbotene Seite. " + + "Beim Ausbruchs-Einstieg gibt es kein berechenbares Ziel: _draw_levels " + + "liefert bewusst nur die nächste Linie je Seite."; // ── Veto: die eigene Bilanz, JE LOT ────────────────────────────────── const v = s.veto || {}; diff --git a/web/index.html b/web/index.html index 659e2c9..0c90408 100644 --- a/web/index.html +++ b/web/index.html @@ -6,7 +6,7 @@ Oil · MT5 - + @@ -347,6 +347,6 @@ - +