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>
188 lines
8.4 KiB
Python
188 lines
8.4 KiB
Python
"""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)
|