diff --git a/CLAUDE.md b/CLAUDE.md index 4ec3e2d..641d423 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1426,6 +1426,38 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: Continuation = breakeven-negativ (−0,011/−0,005, Momentum-Klasse). Damit ist auch die Struktur-Idee empirisch durch — die Kachel bleibt Ist-Zustand-Anzeige (wie TF-Ampel/Squeeze), steuert nichts. +- **Kerzen-Anatomie (`core/candles.py` + Kachel `#card-candles`, 2026-07-31, User-Vorgabe + „Kerzengröße und Dochtlänge … im Kontext"): REINE ANZEIGE — und die Kachel widerspricht + bewusst der Lehrbuch-Deutung.** Erkennt mechanisch aus M5-OHLCV: Marubozu · Langer + Körper · Langer Docht oben/unten · Spinning Top · Doji · Engulfing (Schwellen identisch + zum Backtest, damit Anzeige und Messung dieselbe Sprache sprechen); Refresh ~20 s im + Trend-Loop unter `mt5_lock`, Snapshot `candle_anat {tf, items[], vol_hot_x}`. + **GEMESSEN (`backtest_candles.py`, 80k M5, 2 Halbjahre, Forward-Return 60 min): die + gängige Deutung ist in BEIDEN Hälften INVERTIERT.** ⚠ Methodik-Kern: gerichtete Klassen + werden gegen die **DRIFT-NULL** (bm×E[d]) gelesen, nicht gegen die Baseline — sonst + liest man reine Regime-Drift als Edge (H1 war Abwärts-, H2 Aufwärts-Regime). + Überschuss-Befunde: **„Langer Körper = Fortsetzung" → −0,059/−0,076** (Kurs läuft + GEGEN die Kerze); **„im Trend = Fortsetzung" → −0,132/−0,042** (es ist die KLIMAX-, + nicht die Fortsetzungskerze); **„2× Volumen = echt" → −0,117/−0,204** vs −0,065/−0,035 + bei normalem Volumen (**monoton in die falsche Richtung** — mehr Volumen = stärkere + Gegenbewegung); **„Docht am Level zuverlässig" → in BEIDEN Hälften SCHLECHTER als in + der Range-Mitte**; „mit/gegen Trend" beim Docht kippt (−0,102/+0,089) = Rauschen. + **Folgetest als Handelssignal = ERNEUT VERWORFEN (`backtest_candle_fade.py`, Live-Exit + SL 2,0/Trail 1,5/BE 1,3/Time-Stop 120', Echtkosten):** der Fade der Klimax-Kerze + fällt durch — Kontrolle (ohne Volumen-Filter) −0,080/−0,046; Vol≥2,0 −0,059/**+0,103**; + Vol≥2,5 **+0,144**/−0,080; Vol≥3,0 +0,113/−0,064 → **benachbarte Schwellen exakt + gegenläufig** = Rauschmuster. Einzige beidseitig positive Zelle (Vol≥2,0+Marubozu + +0,036/+0,043) hat n=91/118 bei 7 getesteten Varianten = Mehrfachvergleich-Artefakt. + ⚠ **Methodisch die wichtigste Lehre des Laufs: Informations-Überschuss ≠ handelbarer + Edge.** Dieselbe 2,0-Schwelle liefert im Forward-Fenster −0,117/−0,204 (also +0,12/+0,20 + beim Fade), im echten Trade aber −0,059/+0,103 — die Gegenbewegung ist diffus, der + 2×ATR-Stop wird unterwegs getroffen (WR nur 35–40 %). **Der Weg zum Ziel zählt, nicht + nur das Ziel.** **18. verworfener Signal-Eingriff.** ⚠ **Konsequenz für die UI:** die + Kachel ist bewusst **NEUTRAL** gefärbt (keine grün/rot-Ampel) und der Hinweistext nennt + die **gemessene** Lesart („⚠ Klimax-Kerze — der Kurs läuft danach eher GEGEN die + Kerze"), nicht die Lehrbuch-Lesart — eine Richtungsampel würde hier systematisch + falsch stupsen. Gleiche Korrektur wie bei der Bounce-Anzeige (heißt deshalb + „Überdehnung", bernstein statt grün/rot). Kein Verdict-Gewicht, kein Trade-Trigger. - **Chart-Muster (Doppeltop/-boden) als Signal = VERWORFEN (gemessen `backtest_doubletop.py`, 2026-07-20, User-Idee „Mustererkennung SKS/Doppeltop"):** das am saubersten mechanisch definierbare Umkehrmuster (2 ~gleiche Swing-Pivots + Nackenlinien-Bruch, aus derselben @@ -1647,7 +1679,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=120** (in `web/index.html` hochzählen, siehe Workflows). +- Asset-Version aktuell **v=123** (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/backtest_candle_fade.py b/backtest_candle_fade.py new file mode 100644 index 0000000..9b95c06 --- /dev/null +++ b/backtest_candle_fade.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Fade der Klimax-Kerze: langer Körper + hohes Volumen GEGEN die Körperrichtung. + +Folgetest zu `backtest_candles.py` (2026-07-31). Dort ergab die reine INFORMATIONS- +Messung (Forward-Return 60 min) den einzigen robusten Lead des Laufs: + + Langer Körper Überschuss -0,059 / -0,076 (beide Hälften) + ... + Volumen >= 2x Ø Überschuss -0,117 / -0,204 (monoton verstärkt) + +d. h. nach einer volumenstarken großen Kerze läuft der Kurs GEGEN sie — das Gegenteil +der Lehrbuch-Lesart („2× Volumen = echt"). Brutto +0,117/+0,204 beim Fade, real +kosten aber ~0,265×ATR → unter der Schwelle. OFFEN blieb, ob ein **Live-Exit** mehr +holt als das starre 60-min-Fenster (bei den Chartmustern machte genau das den +Unterschied zwischen negativ und +0,43). + +DESHALB HIER: echter Trade-Sim mit dem LIVE-Exit (Initial-SL 2,0×ATR · Trail 1,5 ab +0,3 · Breakeven 1,3 · Lock ab 3,5 · Time-Stop 120 min) und ECHTKOSTEN (Bar-Spread/ATR). + +⚠ ENTSCHEIDEND — die KONTROLLGRUPPE: „Fade OHNE Volumen-Bedingung". Ohne sie wäre +nicht unterscheidbar, ob der Ertrag vom Volumen kommt oder ob schlicht JEDER Fade +einer großen Kerze + Trailing funktioniert (der Kontrollfehler, der in +`backtest_patterns.py` unentdeckt blieb und den Muster-„Edge" als Exit-Artefakt +entlarvte, als er nachgeholt wurde). + +Urteil „trägt" nur, wenn (a) in BEIDEN Hälften ØR netto > 0, (b) die Volumen-Variante +die Kontrolle in BEIDEN Hälften schlägt, (c) benachbarte Volumen-Schwellen nicht das +Vorzeichen kippen. +""" +import sys +from statistics import median + +import MetaTrader5 as mt5 + +_MAXH = 200 # max. Haltedauer in Bars +_ATRMIN = 0.06 # Live-Floor +_VOL_N = 20 # Fenster für Ø-Volumen / Median-Body +_COOL = 12 # Cooldown nach einem Trade (keine Überlappung) +# Live-Exit +_SL = 2.0; _TRAIL = 1.5; _TRAIL_START = 0.3; _BE = 1.3 +_LOCK_START = 3.5; _LOCK_SCALE = 0.6; _LOCK_MIN = 1.2 +_TIMESTOP = 24 # 120 min = 24 M5-Bars, greift nur in Phase „Init" + + +def _atr_series(H, L, C, p=14): + t = [0.0] + for i in range(1, len(C)): + t.append(max(H[i] - L[i], abs(H[i] - C[i - 1]), abs(L[i] - C[i - 1]))) + out = [] + for i in range(len(C)): + if not i: + out.append(None); continue + w = t[max(1, i - p + 1):i + 1] + out.append(sum(w) / max(1, len(w))) + return out + + +def _ema(v, p): + k = 2.0 / (p + 1); o = []; e = v[0] + for i, x in enumerate(v): + e = x if i == 0 else x * k + e * (1 - k) + o.append(e) + return o + + +def sim(entry, d, atr, H, L, C, j0): + """LIVE-Exit-Modell (identisch zu backtest_trailing_sr.py).""" + sl = entry - d * _SL * atr + hw = entry; rank = 0 + end = min(j0 + _MAXH, len(C) - 1) + for j in range(j0, end + 1): + hi, lo = H[j], L[j] + if (lo <= sl) if d > 0 else (hi >= sl): + return (sl - entry) * d / atr + hw = max(hw, hi) if d > 0 else min(hw, lo) + profit = (hw - entry) * d + ph = 0 if profit < _TRAIL_START * atr else (1 if profit < _LOCK_START * atr else 2) + if ph < rank: + ph = rank + rank = ph + # Time-Stop: nach 120 min noch in Phase „Init" (nie 0,3×ATR im Plus) + if rank == 0 and (j - j0) >= _TIMESTOP: + return (C[j] - entry) * d / atr + if ph == 0: + continue + m = _TRAIL if ph == 1 else max(_LOCK_MIN, _TRAIL * _LOCK_SCALE) + cand = hw - d * m * atr + if profit >= _BE * atr or ph == 2: + cand = max(cand, entry) if d > 0 else min(cand, entry) + sl = max(sl, cand) if d > 0 else min(sl, cand) + return (C[end] - entry) * d / atr + + +def st(Rs): + if not Rs: + return "Trades= 0" + n = len(Rs); w = sum(1 for x in Rs if x > 0); s = sum(Rs) + up = sum(x for x in Rs if x > 0); dn = -sum(x for x in Rs if x < 0) + pf = up / dn if dn > 0 else 9.99 + return (f"Trades={n:>5} WR={100*w/n:>3.0f}% ØR={s/n:+.3f} PF={pf:>4.2f} " + f"Worst={min(Rs):+.2f} ΣR={s:+.0f}") + + +def scan(O, H, L, C, V, A, SP, EF, ES, lo_i, hi_i, vol_x, body_x=1.5, + br_min=0.60, only_with_trend=False): + """vol_x = 0 → KONTROLLE (kein Volumen-Filter).""" + Rs = [] + i = max(lo_i, _VOL_N + 2) + while i < min(hi_i, len(C) - _MAXH - 2): + atr = A[i] + if not atr or atr < _ATRMIN: + i += 1; continue + rng = H[i] - L[i] + body = abs(C[i] - O[i]) + if rng <= 0 or body / rng < br_min: + i += 1; continue + bref = median([abs(C[k] - O[k]) for k in range(i - _VOL_N, i)]) + if bref <= 0 or body < body_x * bref: + i += 1; continue + if vol_x > 0: + vref = sum(V[i - _VOL_N:i]) / _VOL_N + if vref <= 0 or V[i] < vol_x * vref: + i += 1; continue + d_body = 1 if C[i] > O[i] else -1 + if only_with_trend: + trend = 1 if EF[i] > ES[i] else -1 + if d_body != trend: # nur Klimax IM Trend + i += 1; continue + d = -d_body # FADE: gegen die Körperrichtung + entry = C[i] # Einstieg am Close der Signal-Kerze + cost = (SP[i] if SP[i] > 0 else 0.0225) / atr + Rs.append(sim(entry, d, atr, H, L, C, i + 1) - cost) + i += _COOL + return Rs + + +def main(): + n = int(sys.argv[1]) if len(sys.argv) > 1 else 80000 + mt5.initialize(); sym = None + for cand in ("SpotCrude", "USOIL", "WTI", "XTIUSD"): + if mt5.symbol_info(cand): + sym = cand; break + bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, n) + si = mt5.symbol_info(sym); point = si.point + mt5.shutdown() + O = [float(b["open"]) for b in bars]; H = [float(b["high"]) for b in bars] + L = [float(b["low"]) for b in bars]; C = [float(b["close"]) for b in bars] + V = [float(b["tick_volume"]) for b in bars] + SP = [float(b["spread"]) * point for b in bars] + A = _atr_series(H, L, C); EF = _ema(C, 12); ES = _ema(C, 50) + N = len(C); mid = N // 2 + halves = [("H1", 100, mid), ("H2", mid, N)] + + print("=" * 104) + print(f" KLIMAX-FADE — {sym} M5 ({N} Bars) · Live-Exit (SL 2,0 · Trail 1,5 ab 0,3 · " + f"BE 1,3 · Time-Stop 120') · Echtkosten") + print(" Einstieg GEGEN einen langen Körper (Body/Range>=0,60, Body>=1,5x Median)") + print("=" * 104) + + variants = [ + ("KONTROLLE: Fade ohne Volumen-Filter", dict(vol_x=0)), + ("Volumen >= 1,5x Ø", dict(vol_x=1.5)), + ("Volumen >= 2,0x Ø", dict(vol_x=2.0)), + ("Volumen >= 2,5x Ø", dict(vol_x=2.5)), + ("Volumen >= 3,0x Ø", dict(vol_x=3.0)), + ("Vol >= 2,0x + nur IM Trend", dict(vol_x=2.0, only_with_trend=True)), + ("Vol >= 2,0x + Marubozu (BR>=0,90)", dict(vol_x=2.0, br_min=0.90)), + ] + for name, kw in variants: + print(f"\n{name}") + for lbl, a, b in halves: + Rs = scan(O, H, L, C, V, A, SP, EF, ES, a, b, **kw) + print(f" {lbl} {st(Rs)}") + + +if __name__ == "__main__": + main() diff --git a/core/candles.py b/core/candles.py new file mode 100644 index 0000000..ed72430 --- /dev/null +++ b/core/candles.py @@ -0,0 +1,187 @@ +"""core/candles.py — Kerzen-Anatomie (Body/Docht-Klassifikation). REINE ANZEIGE. + +User-Vorgabe 2026-07-31 („Kerzengröße und Dochtlänge … im Kontext mit Trendrichtung, +Volumen und Liquiditätszonen"). Erkennt mechanisch aus OHLCV die sechs Klassen +Marubozu · Langer Körper · Langer Docht (oben/unten) · Spinning Top · Doji · Engulfing. + +⚠⚠ WICHTIG — DIE BESCHRIFTUNG FOLGT DER MESSUNG, NICHT DEM LEHRBUCH. +`backtest_candles.py` (80k M5-Bars, 2 Halbjahre, Forward-Return gegen die +DRIFT-NULL gelesen) hat die gängige Deutung in BEIDEN Hälften **invertiert** +vorgefunden: + + * „Langer Körper = Momentum, im Trend = Fortsetzung" → Überschuss −0,059/−0,076; + IM Trend sogar −0,132/−0,042. Es ist die **Klimax-/Erschöpfungskerze**, nicht + die Fortsetzung. + * „Langer Körper mit 2× Volumen = echt" → −0,117/−0,204, monoton + schlechter mit MEHR Volumen. Volumen bestätigt hier nicht, es kapituliert. + * „Docht am Level = zuverlässig, Range-Mitte = Noise" → am Level in BEIDEN + Hälften SCHLECHTER als in der Range-Mitte. + * „mit/gegen Trend" beim Docht kippt zwischen den Hälften = Rauschen. + +Der Folgetest als Handelssignal (`backtest_candle_fade.py`, Live-Exit + Echtkosten) +fiel ebenfalls durch (benachbarte Volumen-Schwellen exakt gegenläufig). Deshalb: +**KEIN Verdict-Gewicht, kein Trade-Trigger** — die Kachel zeigt nur, WAS gerade +gedruckt wurde, mit der gemessenen Lesart als Hinweis. Wer sie als Signal liest, +handelt gegen die eigene Datenlage. + +Warum trotzdem gebaut: dieselbe Rolle wie `structure.py`/`patterns.py` — sichtbar +machen, was ein Chart-Leser sieht, ohne es zu bewerten. Der Hinweistext ist bewusst +so formuliert, dass er NICHT in die Lehrbuch-Richtung stupst (dieselbe Korrektur wie +bei der Bounce-Anzeige, die deshalb heute „Überdehnung" heißt und bernstein ist). +""" +from __future__ import annotations + +import threading +import time +from statistics import median + +import MetaTrader5 as mt5 + +from core.logger import get_logger +from core.mt5_utils import mt5_lock + +log = get_logger("candles") + +_REFRESH_S = 20.0 # Kachel muss nicht schneller sein als eine M5-Kerze +_N_BARS = 60 # Referenzfenster (Median-Body, Ø-Volumen) +_REF_N = 20 # Fenster für „lang" und „viel Volumen" +_LOOK = 6 # so viele letzte Kerzen werden klassifiziert + +# Schwellen — identisch zu `backtest_candles.py`, damit Anzeige und Messung +# dieselbe Sprache sprechen. +_MARU_BR = 0.90 # Body/Range ab hier = Marubozu +_LONG_BR = 0.60 # Body/Range für „langer Körper" +_LONG_X = 1.5 # Body >= X × Median-Body +_DOJI_BR = 0.10 +_SPIN_BR = 0.30 +_SPIN_W = 0.30 # beide Dochte > X × Range +_WICK = 0.50 # Docht/Range ab hier = „langer Docht" +_VOL_HOT = 2.0 # Volumen >= X × Ø + + +class CandleAnatomy: + """Klassifiziert die letzten Kerzen einer TF. Thread-safe, self-throttled.""" + + def __init__(self, tf: str = "M5"): + self.tf = tf + self._lock = threading.Lock() + self._snap: dict = {} + self._ts = 0.0 + + # ── Klassifikation (rein rechnerisch, ohne MT5) ────────────────────────── + @staticmethod + def classify(o, h, l, c, body_ref, prev=None): + """→ (klasse, richtung, kennzahlen). richtung 0 = keine implizierte Richtung. + + `prev` = (o,c) der Vorkerze, nur für Engulfing. + """ + rng = h - l + if rng <= 0: + return None, 0, {} + body = abs(c - o) + up_w = h - max(o, c) + lo_w = min(o, c) - l + br = body / rng + d = 1 if c > o else -1 if c < o else 0 + m = {"body_pct": round(100 * br), "wick_up_pct": round(100 * up_w / rng), + "wick_lo_pct": round(100 * lo_w / rng)} + long_body = body_ref > 0 and body >= _LONG_X * body_ref + + # Engulfing zuerst prüfen (zwei-Kerzen-Muster schlägt Ein-Kerzen-Form) + if prev and long_body: + po, pc = prev + if d > 0 and pc < po and c >= po and o <= pc: + return "Engulfing", 1, m + if d < 0 and pc > po and c <= po and o >= pc: + return "Engulfing", -1, m + if br >= _MARU_BR: + return "Marubozu", d, m + if br < _DOJI_BR: + return "Doji", 0, m + if br < _SPIN_BR and up_w / rng > _SPIN_W and lo_w / rng > _SPIN_W: + return "SpinningTop", 0, m + if up_w / rng >= _WICK: + return "LangerDochtOben", -1, m + if lo_w / rng >= _WICK: + return "LangerDochtUnten", 1, m + if long_body and br >= _LONG_BR: + return "LangerKoerper", d, m + return None, 0, m + + # ── Anzeige-Text: GEMESSENE Lesart, nicht die Lehrbuch-Lesart ──────────── + @staticmethod + def reading(cls: str, d: int, hot: bool) -> str: + if cls in ("LangerKoerper", "Marubozu", "Engulfing"): + base = ("⚠ Klimax-Kerze — gemessen läuft der Kurs danach eher GEGEN " + "die Kerze (nicht Fortsetzung)") + if hot: + base += " · hohes Volumen verstärkt das (gemessen −0,12…−0,20×ATR)" + return base + if cls in ("LangerDochtOben", "LangerDochtUnten"): + seite = "oben" if cls.endswith("Oben") else "unten" + return (f"Ablehnung nach {seite} — ⚠ am Level gemessen NICHT zuverlässiger " + f"als in der Range-Mitte") + if cls == "SpinningTop": + return "Unentschlossenheit (kleiner Körper, beide Seiten gedrückt)" + if cls == "Doji": + return "Gleichstand — keine Seite dominiert" + return "" + + LABELS = {"Marubozu": "Marubozu", "LangerKoerper": "Langer Körper", + "LangerDochtOben": "Langer Docht oben", + "LangerDochtUnten": "Langer Docht unten", + "SpinningTop": "Spinning Top", "Doji": "Doji", + "Engulfing": "Engulfing"} + + # ── Datenbeschaffung ───────────────────────────────────────────────────── + def refresh_market(self, symbol: str) -> None: + """Im Trend-Loop aufrufen; self-throttled. Nimmt den `mt5_lock` selbst.""" + if not symbol or time.time() - self._ts < _REFRESH_S: + return + self._ts = time.time() + tf_map = {"M1": mt5.TIMEFRAME_M1, "M5": mt5.TIMEFRAME_M5, + "M15": mt5.TIMEFRAME_M15, "M30": mt5.TIMEFRAME_M30, + "H1": mt5.TIMEFRAME_H1} + try: + # ⚠ `mt5_lock` ist ein CONTEXT-MANAGER (kein Lock-Objekt) und liefert + # `got` — nicht .acquire()/.release() verwenden. + with mt5_lock(3.0) as got: + if not got: + return + bars = mt5.copy_rates_from_pos( + symbol, tf_map.get(self.tf, mt5.TIMEFRAME_M5), 0, _N_BARS + 2) + if bars is None or len(bars) < _REF_N + 3: + return + self._build(bars) + except Exception as e: + log.warning(f"candles.refresh_market: {e}") + + def _build(self, bars) -> None: + O = [float(b["open"]) for b in bars]; H = [float(b["high"]) for b in bars] + L = [float(b["low"]) for b in bars]; C = [float(b["close"]) for b in bars] + V = [float(b["tick_volume"]) for b in bars] + # letzte Kerze ist noch NICHT abgeschlossen → nur bis -1 klassifizieren + last = len(C) - 1 + out = [] + for i in range(max(_REF_N, last - _LOOK), last): + bref = median([abs(C[k] - O[k]) for k in range(i - _REF_N, i)]) + vref = sum(V[i - _REF_N:i]) / _REF_N + cls, d, m = self.classify(O[i], H[i], L[i], C[i], bref, + prev=(O[i - 1], C[i - 1])) + if not cls: + continue + vol_x = round(V[i] / vref, 1) if vref > 0 else 0.0 + hot = vol_x >= _VOL_HOT + out.append({ + "cls": cls, "label": self.LABELS.get(cls, cls), "dir": d, + "bars_ago": last - i, "vol_x": vol_x, "hot": hot, + "reading": self.reading(cls, d, hot), **m, + }) + out.reverse() # neueste zuerst + with self._lock: + self._snap = {"tf": self.tf, "items": out, + "vol_hot_x": _VOL_HOT, "ts": time.time()} + + def snapshot(self) -> dict: + with self._lock: + return dict(self._snap) diff --git a/core/engine.py b/core/engine.py index e73cf70..09e16db 100644 --- a/core/engine.py +++ b/core/engine.py @@ -42,6 +42,7 @@ from core.daily_levels import DailyLevels from core.gaps import GapAnalyzer from core.structure import MarketStructure from core.patterns import PatternDetector +from core.candles import CandleAnatomy 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) @@ -165,6 +166,10 @@ class TradingEngine: self.elliott = ElliottAnalyzer(mt5.TIMEFRAME_M5) self.structure = MarketStructure() # Marktstruktur (M30) — reine Anzeige self.patterns = PatternDetector() # visuelle Chartmuster (M30) — reine Anzeige + # Kerzen-Anatomie (M5) — REINE ANZEIGE. Die Lehrbuch-Deutung ist gemessen + # invertiert (`backtest_candles.py`), das Handelssignal fiel durch + # (`backtest_candle_fade.py`) → kein Verdict-Gewicht, kein Trigger. + self.candle_anat = CandleAnatomy("M5") # Echte Liquiditäts-Wände vom HL-Orderbuch (Anzeige im MT5-Chart, kein Signal). # Braucht das HL-Dashboard auf 8001; ist es aus, bleibt der Chart unverändert. from core.hl_walls import HLWalls @@ -539,6 +544,8 @@ class TradingEngine: 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.candle_anat.refresh_market(self.data.symbol) + except Exception as ka: log.warning(f"candles.refresh_market: {ka}") 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 @@ -2704,6 +2711,9 @@ class TradingEngine: "gaps": self.gaps.snapshot(), "structure": self.structure.snapshot(), "patterns": _pat_snap, + # Kerzen-Anatomie (M5) — reine Anzeige, KEIN Verdict-Gewicht (gemessen: + # Lehrbuch-Deutung invertiert, Handelssignal durchgefallen). + "candle_anat": self.candle_anat.snapshot(), "stats_compact": {"week": self._compact_stats("week"), "all": self._compact_stats("all")}, "close_alert_count": self._close_alert_count, diff --git a/web/app.js b/web/app.js index 989a5cc..86b0a24 100644 --- a/web/app.js +++ b/web/app.js @@ -275,6 +275,39 @@ function _patternSvg(type, col) { stroke-linejoin="round" stroke-linecap="round"/>`; } +// Kerzen-Anatomie (M5). ⚠ Bewusst OHNE grün/rot-Richtungsfarbe: die Lehrbuch- +// Deutung ist gemessen invertiert (`backtest_candles.py` — langer Körper läuft eher +// GEGEN sich, Volumen verstärkt das statt zu bestätigen), eine Ampel würde also +// systematisch in die falsche Richtung stupsen. Dieselbe Korrektur wie bei der +// Bounce-Anzeige, die deshalb heute „Überdehnung" heißt. +function renderCandles(ca) { + const el = $("ca-list"); + if (!el) return; + const items = (ca && ca.items) || []; + if (!items.length) { + el.innerHTML = `Keine markante Kerzenform in den letzten Bars.`; + return; + } + el.innerHTML = items.map(m => { + const arr = m.dir > 0 ? "▲" : m.dir < 0 ? "▼" : "◆"; + const ago = m.bars_ago === 1 ? "letzte Kerze" : `vor ${m.bars_ago} Kerzen`; + const vol = m.vol_x + ? `Vol ${fmt(m.vol_x, 1)}×` : ""; + return `
+
${arr} ${m.label} + ${vol}${ago}
+
Körper ${m.body_pct}% · Docht oben ${m.wick_up_pct}% + · unten ${m.wick_lo_pct}%
+
${m.reading || ""}
+
`; + }).join("") + + `
🕯 Reine Anzeige — KEIN Signal. Gemessen + (backtest_candles.py, 80k Bars, 2 Halbjahre): die gängige Deutung ist + invertiert — der lange Körper läuft eher GEGEN sich (−0,06/−0,08×ATR), mit + 2× Volumen noch stärker (−0,12/−0,20). Als Handelssignal durchgefallen + (backtest_candle_fade.py).
`; +} + function renderPatterns(pt) { const el = $("pt-list"); if (!el) return; @@ -398,6 +431,7 @@ function render(d) { renderVerdict(d.verdict || {}, d); renderCircuitBreaker(d.circuit_breaker); renderPatterns(d.patterns); + renderCandles(d.candle_anat); 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 b1a6f5a..4d265b7 100644 --- a/web/index.html +++ b/web/index.html @@ -6,7 +6,7 @@ Oil · MT5 - + @@ -125,6 +125,13 @@
+ +
+

Kerzen-Anatomie M5 · nur Anzeige

+
+
+

KI-Copilot

@@ -230,6 +237,6 @@ - + diff --git a/web/style.css b/web/style.css index 3bf04f3..a7f41d2 100644 --- a/web/style.css +++ b/web/style.css @@ -162,6 +162,24 @@ main{display:grid;grid-template-columns:1fr 1fr;gap:11px;padding:11px} .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} +.pt-disclaimer code{font-family:var(--mono);font-size:11px} +/* Kerzen-Anatomie — bewusst NEUTRAL gefärbt (keine grün/rot-Ampel): die + Lehrbuch-Deutung ist gemessen invertiert, eine Richtungsfarbe würde + systematisch falsch stupsen. Nur „hot" (Volumen>=2x) wird hervorgehoben. */ +#card-candles .ca-tf{font-size:11px;color:var(--dim);font-weight:600;margin-left:6px} +.ca-item{background:var(--surface-2);border:1px solid var(--border); + border-left:3px solid var(--line);border-radius:var(--r-sm); + padding:8px 12px;margin-bottom:7px} +.ca-item.hot{border-left-color:var(--accent)} +.ca-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.ca-name{font-weight:700;font-size:15px;color:var(--bright)} +.ca-vol{font-size:11px;font-weight:700;border-radius:4px;padding:1px 7px; + font-family:var(--mono);background:var(--bg);color:var(--dim); + border:1px solid var(--border)} +.ca-vol.hot{background:#2a1f00;color:var(--accent);border-color:#4a3600} +.ca-ago{margin-left:auto;font-size:12px;color:var(--dim);font-family:var(--mono)} +.ca-bars{font-size:12px;margin-top:3px;font-family:var(--mono);color:var(--dim)} +.ca-note{font-size:12px;color:var(--dim);margin-top:3px;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;