D+A: Klimax-Fade verworfen, Kerzen-Anatomie-Kachel gebaut (reine Anzeige, v=123)

D - backtest_candle_fade.py: der Lead aus backtest_candles.py (Fade der
volumenstarken Klimax-Kerze) faellt im echten Trade-Sim durch. Live-Exit
(SL 2,0/Trail 1,5/BE 1,3/Time-Stop 120') + Echtkosten, mit KONTROLLGRUPPE
"Fade ohne Volumen-Filter" (die Lehre aus backtest_patterns.py).

  Kontrolle   -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 gegenlaeufig = Rauschmuster. Die einzige
beidseitig positive Zelle (Vol>=2,0 + Marubozu, +0,036/+0,043) hat n=91/118
bei 7 Varianten = Mehrfachvergleich-Artefakt. 18. verworfener Eingriff.

Wichtigste Lehre: Informations-Ueberschuss != handelbarer Edge. Dieselbe
2,0-Schwelle liefert im Forward-Fenster +0,12/+0,20 beim Fade, im echten
Trade -0,059/+0,103 - die Gegenbewegung ist diffus, der 2xATR-Stop wird
unterwegs getroffen (WR nur 35-40 %). Der Weg zum Ziel zaehlt.

A - core/candles.py + Kachel #card-candles (M5, ~20 s, unter mt5_lock):
Marubozu, Langer Koerper, Langer Docht oben/unten, Spinning Top, Doji,
Engulfing. Schwellen identisch zum Backtest.

Bewusste UI-Entscheidung: die Kachel ist NEUTRAL gefaerbt (keine gruen/rot-
Ampel) und nennt die GEMESSENE Lesart ("Klimax-Kerze - der Kurs laeuft
danach eher GEGEN die Kerze"), nicht die Lehrbuch-Lesart. Eine
Richtungsampel wuerde hier systematisch falsch stupsen - gleiche Korrektur
wie bei der Bounce-Anzeige. Kein Verdict-Gewicht, kein Trigger.

Beim Bau gefunden: mt5_lock ist ein CONTEXT-MANAGER, kein Lock-Objekt -
.acquire()/.release() wirft. Jetzt "with mt5_lock(3.0) as got".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Axel Hocks
2026-07-31 08:15:46 +02:00
co-authored by Claude Opus 5
parent 7873bf2fb7
commit 03be3b8f5f
7 changed files with 467 additions and 3 deletions
+187
View File
@@ -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)