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:
co-authored by
Claude Opus 5
parent
7873bf2fb7
commit
03be3b8f5f
+187
@@ -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)
|
||||
Reference in New Issue
Block a user