Files
Axel HocksandClaude Opus 5 03be3b8f5f 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>
2026-07-31 08:15:46 +02:00

188 lines
8.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)