diff --git a/CLAUDE.md b/CLAUDE.md index dfc813a..0f235c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1122,6 +1122,22 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: klein & kaum handelbar; NY Fed: SKS nicht profitabel; out-of-sample schwach) UND der Struktur-Klasse (Muster = Swings+Trendlinien = die gerade verworfene Klasse). **11. verworfener Signal-Eingriff — am Signal ist nichts zu holen.** +- **Chartmuster-Kachel `#card-patterns` (`core/patterns.py`, 2026-07-27, User-Idee + „Visuelle Muster-Erkennung"): REINE ANZEIGE, KEIN Signal.** Erkennt mechanisch aus + den M30-Swing-Pivots (k=3, wie `structure.py`): **Doppeltop/-boden, Kopf-Schulter/ + inverse SKS, Tasse+Henkel/invers (heuristisch), auf-/absteigendes + symmetrisches + Dreieck.** Je Muster: Richtung (bull/bear/neutral), Nackenlinie/Trigger, Measured- + Move-Ziel, Status (bildet sich / bestätigt / ungültig→raus), Güte (Extrem-Symmetrie), + Bars-her. Refresh ~30 s im Trend-Loop unter `mt5_lock`; Snapshot `patterns + {patterns[], atr, cur}`; Frontend zeigt Karte + Disclaimer „kein Signal". ⚠ **Signal- + Einfluss ERST nach Backtest** (`backtest_patterns.py`, noch zu bauen) — die + Muster-KLASSE ist bereits 2× als Signal verworfen (`backtest_doubletop.py` oben + + `backtest_structure.py`), also **hohe Beweislast**; die Kachel macht das Muster nur + sichtbar (was ein Kommentator meint), steuert nichts. v2-Kandidaten (Flags/Wimpel, + Rechteck, Rundung) dokumentiert, noch nicht gebaut. Web-Recherche (strike.money, + IG, Axi): Umkehr (Doppel-, SKS, Rundung) vs. Fortsetzung (Dreieck, Flag, Tasse); + Kursziel stets = Muster-Höhe ab Ausbruch projiziert — überall mit dem Caveat + „keine Garantie, nur Indikation". - **Kurslücken-Kachel** (`#card-gaps`): offene D1-Gaps des laufenden Jahres (`gaps.GapAnalyzer`, Refresh ~10 min im Trend-Loop unter `mt5_lock`). Je Gap: Richtung, Zone, Fill-Level, Abstand zum Kurs. Die **Fill-Level fließen als @@ -1214,7 +1230,7 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: Signal → **Hinweis-Dialog „trotzdem eröffnen?"** (überschreibbar). CLOSE bei **negativem** P&L → Sicherheitsabfrage; im Plus/Breakeven direkt. Sonst keine Token-Dialoge (`require_token=false`). Audio braucht 1× Nutzer-Tap. -- Asset-Version aktuell **v=115** (in `web/index.html` hochzählen, siehe Workflows). +- Asset-Version aktuell **v=116** (in `web/index.html` hochzählen, siehe Workflows). Schriftgrößen 2026-07-24 global **+4px** (2× je +2px auf User-Wunsch; Body-Basis 14→18px). ⚠ Betrifft in `style.css` sowohl `font-size:Npx` (71×) ALS AUCH die `font: Npx/…`-**Shorthand** (3×: `.ms-chip`/`.ms-bos-lbl`/`.sqm-badge` — diff --git a/core/engine.py b/core/engine.py index 64aae2d..6c55a70 100644 --- a/core/engine.py +++ b/core/engine.py @@ -41,6 +41,7 @@ from core.market_hours import session_state from core.daily_levels import DailyLevels from core.gaps import GapAnalyzer from core.structure import MarketStructure +from core.patterns import PatternDetector from core.candle_logger import CandleLogger from core.wave_rec import (_atr as _wr_atr, _ema_last, _EMA_FAST, _EMA_SLOW, _TREND_DEADBAND, _STRETCH_MAX) @@ -146,6 +147,7 @@ class TradingEngine: self.wave.set_dead_hours([h.strip() for h in _dh.split(",") if h.strip()]) self.elliott = ElliottAnalyzer(mt5.TIMEFRAME_M5) self.structure = MarketStructure() # Marktstruktur (M30) — reine Anzeige + self.patterns = PatternDetector() # visuelle Chartmuster (M30) — reine Anzeige self.candles = CandleLogger(HISTORY_DB_FILE) # M1-Candle-Log (Daten-Sammlung) # News: eigener Fetch-Loop (versorgt UI /api/news UND Agent-Kontext) tcfg = self.cfg["translation"] @@ -475,6 +477,8 @@ class TradingEngine: except Exception as ee: log.warning(f"elliott.refresh_market: {ee}") try: self.structure.refresh_market(self.data.symbol) except Exception as se2: log.warning(f"structure.refresh_market: {se2}") + try: self.patterns.refresh_market(self.data.symbol) + except Exception as pe2: log.warning(f"patterns.refresh_market: {pe2}") try: self.candles.log(self.data.symbol) # M1-Candle-Log (~55 s) except Exception as ce: log.warning(f"candles.log: {ce}") try: self._evaluate_pbreak_predictions() # ~alle 5 min, self-throttled @@ -2048,6 +2052,7 @@ class TradingEngine: "daily_levels": self.daily.snapshot(), "gaps": self.gaps.snapshot(), "structure": self.structure.snapshot(), + "patterns": self.patterns.snapshot(), "stats_compact": {"week": self._compact_stats("week"), "all": self._compact_stats("all")}, "close_alert_count": self._close_alert_count, diff --git a/core/patterns.py b/core/patterns.py new file mode 100644 index 0000000..9051022 --- /dev/null +++ b/core/patterns.py @@ -0,0 +1,233 @@ +""" +core/patterns.py — Visuelle Chartmuster-Erkennung (ANZEIGE, kein Signal) +======================================================================== +Erkennt mechanisch definierte Chartmuster aus den M30-Swing-Pivots und liefert +sie fürs Dashboard: Typ, Richtung, Nackenlinie/Trigger, Measured-Move-Ziel, +Status (bildet sich / bestätigt / ungültig) und eine Qualitäts-Note. + +Erkannte Muster (v1): + • Doppeltop / Doppelboden (2 gleiche Extrema + Nackenlinie) + • Kopf-Schulter / invers (SKS) (3 Extrema, Mitte am höchsten/tiefsten) + • Tasse+Henkel / invers (heuristisch: abgerundetes Extrem + Henkel) + +⚠ REINE ANZEIGE — KEIN Trade-Trigger, KEIN Verdict-Gewicht. Signal-Einfluss ERST +nach Backtest (`backtest_patterns.py`, noch zu bauen): dieselbe Klasse (Doppeltop, +Struktur) ist im Projekt schon 2× als SIGNAL verworfen (`backtest_doubletop.py`, +`backtest_structure.py`) → hohe Beweislast. Das Modul zeigt nur den Ist-Zustand + +das (mechanische) Measured-Move-Ziel, damit man sieht, was ein Kommentator meint. + +Thread-sicher wie structure.py: refresh_market(sym) holt M30-Bars unter mt5_lock +(~30 s gedrosselt), snapshot() liefert den letzten Stand ohne MT5-Call. +""" +from __future__ import annotations +import threading +import time + +import MetaTrader5 as mt5 + +from core.mt5_utils import mt5_lock +from core.logger import get_logger + +log = get_logger("patterns") + +_TF = mt5.TIMEFRAME_M30 +_N_BARS = 240 +_PIVOT_K = 3 +_REFRESH_S = 30.0 +_MIN_HEIGHT_ATR = 1.2 # Muster-Höhe muss ≥ so viel ATR sein (sonst Rauschen) +_TOL_ATR = 0.7 # zwei "gleiche" Extrema dürfen so weit auseinander (×ATR) +_NECK_TOL_ATR = 0.9 # Nackenlinie "flach": zwei Tröge/Hochs so weit (×ATR) +_MAX_LOOKBACK = 9 # nur die letzten N Swings betrachten (aktuelle Muster) + + +def _atr(highs, lows, closes, p=14): + trs = [] + for i in range(1, len(closes)): + trs.append(max(highs[i]-lows[i], abs(highs[i]-closes[i-1]), abs(lows[i]-closes[i-1]))) + return (sum(trs[-p:]) / min(len(trs), p)) if trs else None + + +def _pivots(highs, lows, k): + """Alternierende Swing-Punkte → [(index, price, 'H'/'L')] (wie structure.py).""" + n = len(highs); raw = [] + for i in range(k, n - k): + if highs[i] == max(highs[i-k:i+k+1]) and highs[i] > highs[i-1] and highs[i] >= highs[i+1]: + raw.append((i, highs[i], "H")) + elif lows[i] == min(lows[i-k:i+k+1]) and lows[i] < lows[i-1] and lows[i] <= lows[i+1]: + raw.append((i, lows[i], "L")) + out = [] + for p in raw: + if out and out[-1][2] == p[2]: + if (p[2] == "H" and p[1] > out[-1][1]) or (p[2] == "L" and p[1] < out[-1][1]): + out[-1] = p + else: + out.append(p) + return out + + +def _sim(a, b, atr): + """Ähnlichkeit zweier Preise (1 = identisch, 0 = ≥ _TOL_ATR entfernt).""" + return max(0.0, 1.0 - abs(a - b) / (_TOL_ATR * atr)) if atr else 0.0 + + +def _status(dir_, trigger, cur, extreme): + """bestätigt (Trigger gebrochen) / ungültig (Extrem überschritten) / bildet sich.""" + if dir_ == "bear": + if cur < trigger: return "confirmed" + if cur > extreme: return "invalidated" + else: + if cur > trigger: return "confirmed" + if cur < extreme: return "invalidated" + return "forming" + + +def _detect(piv, cur, atr, last_idx): + """Sucht Muster in den letzten Swings. → Liste Muster-dicts.""" + out = [] + P = piv[-_MAX_LOOKBACK:] + if len(P) < 3: + return out + pr = [p[1] for p in P]; kd = [p[2] for p in P]; ix = [p[0] for p in P] + + def add(typ, name, dir_, quality, trigger, extreme, note): + h = abs(extreme - trigger) + if h < _MIN_HEIGHT_ATR * atr: # zu flach = Rauschen + return + target = trigger - h if dir_ == "bear" else trigger + h + out.append({ + "type": typ, "name": name, "dir": dir_, + "quality": round(quality, 2), "trigger": round(trigger, 3), + "target": round(target, 3), "height_atr": round(h / atr, 1), + "status": _status(dir_, trigger, cur, extreme), + "bars_ago": last_idx - ix[-1], "note": note, + }) + + # über alle 3er/5er-Fenster am aktuellen Rand + for s in range(len(P) - 2): + w = list(zip(kd[s:], pr[s:], ix[s:])) + # ── Doppeltop: H, L, H (zwei ~gleiche Hochs, Trog dazwischen) ── + if len(w) >= 3 and w[0][0] == "H" and w[1][0] == "L" and w[2][0] == "H": + Ha, Lm, Hb = w[0][1], w[1][1], w[2][1] + q = _sim(Ha, Hb, atr) + if q > 0.35 and Lm < min(Ha, Hb): + add("double_top", "Doppeltop", "bear", q, Lm, max(Ha, Hb), + f"zwei Hochs {Ha:.2f}/{Hb:.2f}, Nackenlinie {Lm:.2f}") + # ── Doppelboden: L, H, L ── + if len(w) >= 3 and w[0][0] == "L" and w[1][0] == "H" and w[2][0] == "L": + La, Hm, Lb = w[0][1], w[1][1], w[2][1] + q = _sim(La, Lb, atr) + if q > 0.35 and Hm > max(La, Lb): + add("double_bottom", "Doppelboden", "bull", q, Hm, min(La, Lb), + f"zwei Tiefs {La:.2f}/{Lb:.2f}, Nackenlinie {Hm:.2f}") + # ── Kopf-Schulter (SKS): H, L, H, L, H (Kopf = mittleres Hoch am höchsten) ── + if len(w) >= 5 and [x[0] for x in w[:5]] == ["H", "L", "H", "L", "H"]: + ls, t1, head, t2, rs = (w[i][1] for i in range(5)) + if head > ls and head > rs: + neck = (t1 + t2) / 2.0 + q = _sim(ls, rs, atr) * _sim(t1, t2, atr) + if q > 0.25 and abs(t1 - t2) < _NECK_TOL_ATR * atr: + add("hs", "Kopf-Schulter", "bear", q, neck, head, + f"Kopf {head:.2f}, Schultern {ls:.2f}/{rs:.2f}, Nacken {neck:.2f}") + # ── Inverse SKS: L, H, L, H, L ── + if len(w) >= 5 and [x[0] for x in w[:5]] == ["L", "H", "L", "H", "L"]: + ls, t1, head, t2, rs = (w[i][1] for i in range(5)) + if head < ls and head < rs: + neck = (t1 + t2) / 2.0 + q = _sim(ls, rs, atr) * _sim(t1, t2, atr) + if q > 0.25 and abs(t1 - t2) < _NECK_TOL_ATR * atr: + add("inv_hs", "Inverse SKS", "bull", q, neck, head, + f"Kopf {head:.2f}, Schultern {ls:.2f}/{rs:.2f}, Nacken {neck:.2f}") + # ── (Inverse) Tasse+Henkel (heuristisch): Rand-Extrema ~gleich, Boden/Top + # dazwischen, breit; Henkel = letzter kleiner Gegen-Swing. ── + if len(w) >= 3: + span = w[2][2] - w[0][2] # Bars zwischen den Rand-Punkten + if w[0][0] == "L" and w[1][0] == "H" and w[2][0] == "L" and span >= 10: + rim_l, top, rim_r = w[0][1], w[1][1], w[2][1] # inverse Tasse (abger. Top) + q = _sim(rim_l, rim_r, atr) * 0.9 + if q > 0.4 and top > max(rim_l, rim_r): + add("inv_cup", "Inverse Tasse+Henkel", "bear", q, min(rim_l, rim_r), top, + f"abgerundetes Top {top:.2f}, Rand ~{rim_r:.2f} (Henkel-Unterkante)") + if w[0][0] == "H" and w[1][0] == "L" and w[2][0] == "H" and span >= 10: + rim_l, bot, rim_r = w[0][1], w[1][1], w[2][1] # Tasse (abger. Boden) + q = _sim(rim_l, rim_r, atr) * 0.9 + if q > 0.4 and bot < min(rim_l, rim_r): + add("cup", "Tasse+Henkel", "bull", q, max(rim_l, rim_r), bot, + f"abgerundeter Boden {bot:.2f}, Rand ~{rim_r:.2f} (Henkel-Oberkante)") + + # ── Dreiecke (Fortsetzung): letzte 2 Hochs + 2 Tiefs, Steigungen prüfen ── + highs = [(x[2], x[1]) for x in zip(kd, pr, ix) if x[0] == "H"][-2:] + lows = [(x[2], x[1]) for x in zip(kd, pr, ix) if x[0] == "L"][-2:] + if len(highs) == 2 and len(lows) == 2: + (ih1, h1p), (ih2, h2p) = highs + (il1, l1p), (il2, l2p) = lows + flat_h = abs(h2p - h1p) <= _NECK_TOL_ATR * atr + flat_l = abs(l2p - l1p) <= _NECK_TOL_ATR * atr + rising_l = l2p > l1p + 0.4 * atr + falling_h = h2p < h1p - 0.4 * atr + wide = abs(max(h1p, h2p) - min(l1p, l2p)) + if wide >= _MIN_HEIGHT_ATR * atr: + if flat_h and rising_l: # aufsteigendes Dreieck (bullisch) + res_ = max(h1p, h2p) + add("tri_asc", "Aufsteigendes Dreieck", "bull", 0.6, res_, min(l1p, l2p), + f"flacher Widerstand {res_:.2f}, steigende Tiefs") + elif flat_l and falling_h: # absteigendes Dreieck (bärisch) + sup_ = min(l1p, l2p) + add("tri_desc", "Absteigendes Dreieck", "bear", 0.6, sup_, max(h1p, h2p), + f"flacher Support {sup_:.2f}, fallende Hochs") + elif falling_h and rising_l: # symmetrisch → Ausbruch offen (nur Anzeige) + out.append({ + "type": "tri_sym", "name": "Symmetrisches Dreieck", "dir": "neutral", + "quality": 0.5, "trigger": round((max(h1p, h2p) + min(l1p, l2p)) / 2, 3), + "target": None, "height_atr": round(wide / atr, 1), + "status": "forming", "bars_ago": last_idx - ix[-1], + "note": f"konvergierend (Hochs fallen, Tiefs steigen) — Ausbruchsrichtung offen", + }) + + # Duplikate (gleicher Typ+Trigger) zusammenfassen, beste Qualität behalten + best = {} + for m in out: + key = (m["type"], round(m["trigger"], 2)) + if key not in best or m["quality"] > best[key]["quality"]: + best[key] = m + # ungültige raus, dann nach Status (confirmed/forming) + Qualität + Recency + res = [m for m in best.values() if m["status"] != "invalidated"] + rank = {"confirmed": 2, "forming": 1} + res.sort(key=lambda m: (rank.get(m["status"], 0), m["quality"], -m["bars_ago"]), reverse=True) + return res + + +class PatternDetector: + def __init__(self): + self._snap = {"patterns": [], "atr": None, "ts": 0.0} + self._last_refresh = 0.0 + self._lock = threading.Lock() + + def refresh_market(self, sym: str): + now = time.time() + if now - self._last_refresh < _REFRESH_S: + return + self._last_refresh = now + try: + with mt5_lock(timeout=2) as got: + if not got: + return + bars = mt5.copy_rates_from_pos(sym, _TF, 0, _N_BARS) + if bars is None or len(bars) < 60: + return + H = [float(b["high"]) for b in bars] + L = [float(b["low"]) for b in bars] + C = [float(b["close"]) for b in bars] + atr = _atr(H, L, C) + if not atr or atr <= 0: + return + piv = _pivots(H, L, _PIVOT_K) + patterns = _detect(piv, C[-1], atr, len(H) - 1) + with self._lock: + self._snap = {"patterns": patterns, "atr": round(atr, 4), + "cur": round(C[-1], 3), "ts": now} + except Exception as e: + log.warning(f"patterns.refresh_market: {e}") + + def snapshot(self) -> dict: + with self._lock: + return dict(self._snap) diff --git a/web/app.js b/web/app.js index 05f0071..cdf465e 100644 --- a/web/app.js +++ b/web/app.js @@ -224,6 +224,37 @@ function renderCircuitBreaker(cb) { } } +// Chartmuster (visuell) — reine Anzeige. Status: bildet sich / bestätigt / (ungültig raus). +function renderPatterns(pt) { + const el = $("pt-list"); + if (!el) return; + const items = (pt && pt.patterns) || []; + if (!items.length) { + el.innerHTML = `Aktuell kein klares Muster erkannt.`; + return; + } + el.innerHTML = items.map(m => { + const dirCls = m.dir === "bull" ? "up" : m.dir === "bear" ? "down" : "dim"; + const arr = m.dir === "bull" ? "▲" : m.dir === "bear" ? "▼" : "◆"; + const st = m.status === "confirmed" + ? `bestätigt` + : `bildet sich`; + const tgt = m.target != null + ? `Ziel ${fmt(m.target, 2)}` : `Ausbruch offen`; + const q = Math.round((m.quality || 0) * 100); + return `
+
${arr} ${m.name}${st} + Güte ${q}%
+
Trigger ${fmt(m.trigger, 2)} · ${tgt} + · Höhe ${m.height_atr}×ATR · vor ${m.bars_ago} Bars
+
${m.note || ""}
+
`; + }).join("") + + `
📊 Reine Anzeige (mechanisch aus M30-Pivots) — KEIN Signal, + kein Einfluss auf die Empfehlung. Muster-Klasse im Projekt als Signal noch unbelegt; + Einfluss erst nach Backtest.
`; +} + function render(d) { // ── Header ────────────────────────────── $("conn").className = "dot " + (d.connected ? "on" : "off"); @@ -314,6 +345,7 @@ function render(d) { // ── Gesamtempfehlung (aggregiert) ─────── renderVerdict(d.verdict || {}, d); renderCircuitBreaker(d.circuit_breaker); + renderPatterns(d.patterns); renderStructure(d.structure); renderStatsCompact(d.stats_compact, d.market); renderGaps(d.gaps, d.market); diff --git a/web/index.html b/web/index.html index 18a1088..e25627d 100644 --- a/web/index.html +++ b/web/index.html @@ -6,7 +6,7 @@ Oil · MT5 - + @@ -116,6 +116,12 @@ + +
+

Chartmuster M30 · visuell

+
+
+

KI-Copilot

@@ -220,6 +226,6 @@ - + diff --git a/web/style.css b/web/style.css index 1ea34a7..1471a17 100644 --- a/web/style.css +++ b/web/style.css @@ -141,6 +141,21 @@ main{display:grid;grid-template-columns:1fr 1fr;gap:11px;padding:11px} .circuit.tripped{background:#3d1a1a;color:#ff6b6b;border:1px solid var(--down); box-shadow:0 0 0 1px var(--down)} .circuit.near{background:#2a1f00;color:var(--accent);border:1px solid #4a3600} +/* Chartmuster-Karte (visuell, reine Anzeige) */ +#card-patterns .pt-tf{font-size:11px;color:var(--dim);font-weight:600;margin-left:6px} +.pt-item{background:var(--surface-2);border:1px solid var(--border);border-left:3px solid var(--line); + border-radius:var(--r-sm);padding:9px 12px;margin-bottom:8px} +.pt-item.up{border-left-color:var(--up)} .pt-item.down{border-left-color:var(--down)} +.pt-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.pt-name{font-weight:700;font-size:15px} +.pt-item.up .pt-name{color:var(--up)} .pt-item.down .pt-name{color:var(--down)} +.pt-badge{font-size:11px;font-weight:700;border-radius:4px;padding:1px 7px} +.pt-badge.conf{background:#0d2a16;color:var(--up);border:1px solid #1a4731} +.pt-badge.form{background:#2a1f00;color:var(--accent);border:1px solid #4a3600} +.pt-q{margin-left:auto;font-size:12px;color:var(--dim);font-family:var(--mono)} +.pt-det{font-size:13px;margin-top:4px;font-family:var(--mono);color:var(--bright)} +.pt-note{font-size:12px;color:var(--dim);margin-top:3px} +.pt-disclaimer{font-size:12px;color:var(--dim);margin-top:6px;line-height:1.4} #card-verdict{border-color:var(--accent)} .vd-head{display:flex;align-items:center;gap:14px;margin-bottom:10px} .vd-ring{width:60px;height:60px;border-radius:50%;flex:0 0 auto;