Anlass: "pruefe die Logik im umgekehrten Fall, fallender Kurs". 1) Spiegeltest bestanden. Mit reversal_enabled=false ist das Verhalten ueber die ganze Ueberdehnungs-Spanne exakt symmetrisch (steigend/LONG <-> fallend/SHORT): gleiche Signale, gleiche Konfidenz, gleicher Anti-Ueberdehnungs-Schnitt bei 3,5. 2) Dabei gefunden: die Winkel-Beschriftung war ueberall invertiert. calc_trend_angle liefert 0 Grad = AUFWAERTS ... 180 = ABWAERTS, also ist ad = angle-90 NEGATIV bei steigendem Kurs. Ein LONG in einem steigenden Markt bekam daher "Winkel gegen EMA" (-10), ein LONG in einem fallenden "Winkel bestaetigt" (+5). ABER: backtest_angle.py benutzt DIESELBE verdrehte Beschriftung (a5_dis = a5 < 90-dead fuer LONG heisst "dagegen", ist aber STEIGEND). Der dokumentierte Befund "dafuer/neutral +0,055 vs dagegen +0,025" bedeutet richtig gelesen: LONG bei FALLENDEM Kurzfrist-Winkel (= Pullback) traegt doppelt so gut. Das ist der mehrfach belegte Pullback-Effekt. => Die Gewichtung war von Anfang an NUMERISCH RICHTIG. Nicht umgedreht. Geaendert wurden nur die Reason-Texte und Kommentare. Verifiziert: Konfidenzwerte vorher/nachher identisch (60/70/75 je Richtung). Lehre: der erste Verdacht "systematisch invertierte Logik" war falsch - die Zahlen stimmten, die Sprache log. Bei einer verdrehten Konvention erst pruefen, ob die MESSUNG dieselbe Verdrehung teilt, bevor man den Code "repariert". Beim Reversal war es anders: dort wies die eigenstaendige Messung das Setup unabhaengig von der Beschriftung als negativ aus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
923 lines
50 KiB
Python
923 lines
50 KiB
Python
"""
|
||
core/wave_rec.py — Trend-Empfehlungsmodul (EMA-Trend)
|
||
======================================================
|
||
Signalrichtung folgt dem geglätteten Trend (EMA12 vs EMA50) der gewählten
|
||
Zeitebene — NICHT mehr dem verrauschten ZigZag-Swing. Grund: Backtests zeigten,
|
||
dass das alte Momentum-ZigZag ~0 Edge hatte (kaufte die Spitzen von Bounces),
|
||
während EMA-Trendfolge einen klaren, positiven Edge liefert.
|
||
|
||
Signal:
|
||
EMA12 > EMA50 → LONG | EMA12 < EMA50 → SHORT
|
||
|EMA12−EMA50| < _TREND_DEADBAND×ATR → WARTEN (kein klarer Trend / Chop)
|
||
Kurs > _STRETCH_MAX×ATR über/unter EMA50 → WARTEN (überdehnt, kein Spät-Einstieg)
|
||
|
||
Konfidenz: 60 % Basis, +Trendstärke (EMA-Abstand in ATR), +Einstiegsqualität
|
||
(nahe der EMA = besseres CRV, kein Hinterherkaufen),
|
||
+Traders-Union-Bestätigung (5m/15m); stark gegenläufige TU blockt.
|
||
|
||
Arbeitsteilung (Thread-sicher):
|
||
set_timeframe(tf) → vom KI-Agenten gewählt (M1/M5/M15/M30/H1)
|
||
refresh_market(sym) → alle ~5 s im Hintergrund (MT5-Call: Bars + EMAs)
|
||
signal() → aktueller, fertig gefilterter rec-Dict (kein MT5)
|
||
snapshot() → Trend-Status für die Anzeige (EMA als wave_start,
|
||
Abstand zur EMA in ATR als move_atr)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
import threading
|
||
import time
|
||
from datetime import datetime
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import MetaTrader5 as mt5
|
||
|
||
from core.mt5_utils import mt5_lock
|
||
from core.market_hours import session_state
|
||
from core.analysis import calc_trend_angle
|
||
from core.logger import get_logger
|
||
|
||
log = get_logger("wave")
|
||
|
||
_N_BARS = 180
|
||
_ATR_PERIOD = 14
|
||
_STALE_S = 150
|
||
_BASE_CONF = 60
|
||
_MIN_CONF = 55 # Signale < 55 % Konfidenz → WARTEN. Gemessen
|
||
# (backtest_conf.py): Band 40–54 % = NEGATIVER Edge
|
||
# (−0,025), ≥55 % = +0,058. Gate hebt Edge + Ertrag.
|
||
|
||
# Signalrichtung = geglätteter EMA-Trend (per Backtest mit Edge belegt; das alte
|
||
# ZigZag-Momentum hatte ~0 Edge / kaufte Spitzen). Totband filtert Chop, die
|
||
# Anti-Überdehnung verhindert Spät-Einstiege weit weg von der EMA.
|
||
_EMA_FAST = 12
|
||
_EMA_SLOW = 50
|
||
_TREND_DEADBAND = 0.15 # |EMA12 − EMA50| < x×ATR → kein klarer Trend → WARTEN
|
||
_STRETCH_MAX = 3.5 # Kurs > x×ATR über/unter EMA50 → überdehnt → kein Einstieg.
|
||
# 2,5→3,5 (backtest_stretch.py): +26 % Signale (45→57 % der
|
||
# Zeit), Bänder 2,5–3,5 noch positiv, Gesamtertrag steigt.
|
||
# NICHT höher: ab 3,5×ATR kippt der Edge klar negativ (−0,13).
|
||
_REVERSAL_STRETCH = 3.0 # Bounce/Reversal-Trigger — ENTKOPPELT von _STRETCH_MAX:
|
||
# ab |stretch|≥3,0×ATR + Winkeldrehung antizyklischer
|
||
# Einstieg (mehr Bounces als bei 3,5). Gemessen
|
||
# (backtest_bounce.py, Exit-Sim): Ø-R +0,185, PF 1,35,
|
||
# Treffer 70 %, ~1,5× mehr Bounces als 3,5. Anti-Über-
|
||
# dehnung (Trend-Spät-Sperre) bleibt bei _STRETCH_MAX=3,5.
|
||
|
||
# Volatilitäts-Squeeze-Breakout (additiver Setup, gemessen `backtest_breakout_squeeze.py`,
|
||
# 2-Stichproben-positiv & robust über Box 10–20 / k 0,1–0,2): Box = Spanne der letzten
|
||
# _SQ_N abgeschlossenen M5-Bars; ist sie ≤ _SQ_MULT×ATR (Kompression) und bricht der
|
||
# Kurs _SQ_K×ATR über/unter die Box → Ausbruchssignal. Live-Params = die getesteten.
|
||
_SQ_N = 10 # Box-Länge (M5-Bars)
|
||
_SQ_MULT = 2.5 # Squeeze: Box ≤ _SQ_MULT×ATR = Kompression
|
||
_SQ_K = 0.1 # Ausbruch k×ATR über/unter die Box-Grenze
|
||
_SQ_REFRESH_S = 5.0 # eigener, schneller M5-Fetch NUR für den Squeeze (entkoppelt von
|
||
# der 15-s-Ampel-Drossel) → Ausbruch-Erkennung ~15 s→5 s, gleiche
|
||
# validierte M5-Regel (Variante A, 2026-07-21).
|
||
|
||
# Higher-TF-Gegen-Trend-Filter: Signal NUR, wenn der übergeordnete Trend (M30,
|
||
# EMA12 vs EMA50) nicht klar dagegen steht. Per Backtest belegt: verdoppelt den
|
||
# Ø-Edge pro Trade (+0,012 → +0,027), Treffer 52→54 %; wirft Gegen-Trend-Signale
|
||
# raus (z. B. Short im M30-Aufwärtstrend). Nur für Basis-TF < M30 aktiv.
|
||
_HTF = mt5.TIMEFRAME_M30
|
||
_HTF_LABEL = "M30"
|
||
_EQ_TOL_ATR = 0.15 # „gleich" für Equal Highs/Lows: beide Pivots innerhalb x×ATR
|
||
_EQ_MIN_H_ATR = 0.10 # Mindest-Bandhöhe der Liquiditätszone (sonst unsichtbar dünn)
|
||
_HTF_DEADBAND = 0.15 # |EMA12−EMA50| < x×ATR(M30) → HTF gilt als neutral
|
||
|
||
# Multi-TF-Konfluenz (nur Konfidenz/Anzeige): wenn M30 UND H1 die Richtung
|
||
# bestätigen, ver-3,5-facht sich der Ø-Edge (+0,016→+0,056, backtest_improve.py).
|
||
_CONFLUENCE_BONUS = 12 # M30 + H1 beide dafür → Top-Setup (⭐⭐)
|
||
_H1_AGAINST_PEN = 8 # H1 steht gegen die Richtung (M30 ok/neutral) → schwächer
|
||
|
||
# Regressions-Winkel der Basis-TF als schnellerer Wende-Detektor gegen die
|
||
# nachlaufende EMA (backtest_angle.py: Winkel dafür/neutral +0,055 vs dagegen
|
||
# +0,025). NUR Konfidenz/Warnung — als hartes Gate senkt es den Gesamtertrag.
|
||
_ANGLE_LR = 14 # Regressionsfenster (= ANGLE_LR_BARS)
|
||
_ANGLE_DEAD = 2.0 # Totband um 90° (Grad) → darunter „neutral"
|
||
# ⚠ Namen historisch, Bedeutung 2026-08-04 richtiggestellt (Zahlen unverändert):
|
||
_ANGLE_BONUS = 5 # Kurzfrist-Winkel läuft GEGEN das Signal = PULLBACK → besser
|
||
_ANGLE_PENALTY = 10 # Kurzfrist-Winkel läuft schon MIT = kein Pullback → schlechter
|
||
|
||
# Tageszeit-Gate — REAKTIVIERT 2026-07-13 (User-OK) nach Echtkosten-Messung
|
||
# (`backtest_realcosts.py`, Kosten = Bar-Spread/ATR statt pauschal 0,1):
|
||
# 0–7 Uhr = Nacht-Kostenfalle (konstanter Spread ÷ niedriger Nacht-ATR =
|
||
# 0,32–0,50×ATR Kosten → Edge sicher aufgefressen; in BEIDEN Hälften negativ).
|
||
# 12 & 16 Uhr = 3× unabhängig negativ gemessen (hourly, hourly_split, realcosts).
|
||
# Gate-Politik „0–7+12" verbesserte BEIDE Hälften (H1 −4038→−1870, H2 +100→+1191).
|
||
# Robust positiv sind nur 21–22 Uhr (US-Session). (Zwischenzeitlich war das Gate
|
||
# auf User-Wunsch ganz aus; Rückholung gezielt & gemessen, kein Pauschal-Gate.)
|
||
_DEAD_HOURS = (0, 1, 2, 3, 4, 5, 6, 7, 12, 16)
|
||
_BERLIN = ZoneInfo("Europe/Berlin")
|
||
# Hohe Vola (oberes ATR-Terzil, ~>0,27 bei WTI-M5) = schwächster/negativer Edge
|
||
# → nur Konfidenz-Abzug (Schwelle regime-abhängig, daher KEIN hartes Gate).
|
||
_ATR_HIGH = 0.27
|
||
_ATR_HIGH_PEN = 8
|
||
|
||
# S/R-Kontext (vom Engine gesetzt): Konfidenz dämpfen, wenn der Einstieg direkt
|
||
# in ein Gegen-Level läuft (wenig Raum / schlechtes CRV), anheben bei Rückenwind.
|
||
_SR_NEAR_ATR = 0.5 # "dicht an" einer Linie = innerhalb x×ATR
|
||
_SR_PENALTY = 12 # Konfidenz-Abzug: Trade läuft ins Level (wenig Raum)
|
||
_SR_BONUS = 8 # Konfidenz-Bonus: Trade startet vom Level mit Rückenwind
|
||
|
||
# Börsen-Sessions (Frankfurt 9:00 / US 15:00): Vorsicht direkt nach Open,
|
||
# Bonus während aktiver Session, Dämpfung in dünnen Zeiten.
|
||
_SESSION_CAUTION = 15 # Abzug in den ersten Minuten nach einem Open (Whipsaw)
|
||
_SESSION_BONUS = 5 # Bonus während aktiver US/DE-Session (Liquidität)
|
||
_SESSION_OFFHOURS = 8 # Abzug außerhalb DE/US-Session (dünne Liquidität)
|
||
|
||
# TradersUnion ist KEIN harter Blocker mehr (blockte zu oft starke EMA-Trends),
|
||
# sondern ein Konfidenz-Faktor je TF (5m/15m): bestätigt +Bonus, dagegen −Abzug.
|
||
_TU_BONUS = 6 # je TF, das die Richtung bestätigt
|
||
_TU_PENALTY = 8 # je TF, das gegen die Richtung steht (beide = −16)
|
||
|
||
_TF_LABELS = {
|
||
mt5.TIMEFRAME_M1: "M1",
|
||
mt5.TIMEFRAME_M5: "M5",
|
||
mt5.TIMEFRAME_M15: "M15",
|
||
mt5.TIMEFRAME_M30: "M30",
|
||
mt5.TIMEFRAME_H1: "H1",
|
||
}
|
||
|
||
|
||
def _atr(highs, lows, closes, period=_ATR_PERIOD):
|
||
trs = []
|
||
for i in range(1, len(highs)):
|
||
trs.append(max(highs[i] - lows[i],
|
||
abs(highs[i] - closes[i - 1]),
|
||
abs(lows[i] - closes[i - 1])))
|
||
if not trs:
|
||
return None
|
||
return sum(trs[-period:]) / min(len(trs), period)
|
||
|
||
|
||
def _ema_last(vals, period):
|
||
"""Letzter EMA-Wert der Reihe (genügt für den Trend-Vergleich)."""
|
||
if not vals:
|
||
return None
|
||
k = 2.0 / (period + 1)
|
||
e = vals[0]
|
||
for v in vals[1:]:
|
||
e = v * k + e * (1.0 - k)
|
||
return e
|
||
|
||
|
||
def _ema_series(vals, period):
|
||
"""Komplette EMA-Reihe (für die Kreuzungs-/Wende-Erkennung je TF)."""
|
||
k = 2.0 / (period + 1)
|
||
out = []
|
||
e = vals[0] if vals else 0.0
|
||
for i, v in enumerate(vals):
|
||
e = v if i == 0 else v * k + e * (1.0 - k)
|
||
out.append(e)
|
||
return out
|
||
|
||
|
||
class WaveRecommender:
|
||
"""ATR-ZigZag-Wellen-Empfehlung, bestätigt durch Traders-Union-Daten."""
|
||
|
||
def __init__(self, tu_provider, timeframe: int = mt5.TIMEFRAME_M5):
|
||
self.tu = tu_provider
|
||
self._lock = threading.Lock()
|
||
self._tf = timeframe
|
||
self._rec: dict | None = None
|
||
self._snap: dict = {}
|
||
self._ts: float = 0.0
|
||
self._error: str | None = None
|
||
self._sr_res: float | None = None # nächster Widerstand über Preis
|
||
self._sr_sup: float | None = None # nächste Unterstützung unter Preis
|
||
self._turn_due: float = 0.0 # nächster TF-Ampel-Refresh (Throttle)
|
||
self._sq_due: float = 0.0 # nächster schneller M5-Squeeze-Fetch (~5 s)
|
||
self._dead_hours: set = set(_DEAD_HOURS) # Tageszeit-Gate (per Config setzbar)
|
||
self._last_tf_turns: dict = {} # zuletzt berechnete TF-Wenden
|
||
self._last_bounce: dict = {"state": None, "dir": None, "tf": None} # Multi-TF-Bounce
|
||
# ATR-Breakout-Bestätigung: ein Richtungssignal wird erst durchgelassen,
|
||
# wenn der Kurs k×ATR in Signalrichtung gelaufen ist (gemessen ~2× Edge,
|
||
# `backtest_breakout.py`). 0 = aus. `_pend` = laufende Bestätigung.
|
||
self._breakout_k = 1.0
|
||
self._breakout_timeout_s = 3600.0
|
||
self._pend: dict | None = None
|
||
self._squeeze: dict | None = None # Volatilitäts-Squeeze-Breakout (M5)
|
||
# Entry-Raum-Gate (gemessen `backtest_entryroom.py`, monoton in BEIDEN
|
||
# Hälften): kein Entry, wenn das Gegenlevel < X×ATR entfernt ist — der
|
||
# Ertrag ist durch den S/R-Auto-Close gedeckelt, Kosten fressen den Rest.
|
||
# 0 = aus. Nur Live-Pfad (refresh_market); Backtests via _build bleiben frei.
|
||
self._entry_room_atr = 0.0
|
||
# Reversal/Bounce-Setup an/aus. ⚠ Modul-Default bewusst TRUE: sämtliche
|
||
# Backtests rufen dasselbe `_build` — ein False-Default würde alle
|
||
# bestehenden Messungen still verändern. Abgeschaltet wird über die
|
||
# Config (`[trading] reversal_enabled`, s. `engine`), nicht hier.
|
||
self._reversal_enabled = True
|
||
|
||
def set_reversal_enabled(self, on: bool):
|
||
"""Antizyklisches Reversal-Setup (WAVE_REV_*) an/aus.
|
||
|
||
⚠ GEMESSEN NEGATIV (`backtest_reversal_angle.py`, 2026-08-04, 80k M5,
|
||
kanonischer Exit, Echtkosten, 2 Halbjahre): ØR −0,122/−0,031, PF 0,79/0,94.
|
||
Auch die vorzeichenkorrigierte Variante (−0,173/−0,061) und die Kontrolle
|
||
ohne Winkelbedingung (−0,099/−0,023) sind beidhälftig negativ. Dazu hebt
|
||
das Setup ZWEI gemessen positive Schutzmechanismen aus (Anti-Überdehnung
|
||
und den M30-Gegen-Trend-Filter, Edge ×2). Deshalb live abgeschaltet.
|
||
`true` stellt das alte Verhalten wieder her.
|
||
"""
|
||
with self._lock:
|
||
self._reversal_enabled = bool(on)
|
||
log.info(f"Reversal-Setup {'AN' if on else 'AUS'}"
|
||
+ ("" if on else " (gemessen negativ, backtest_reversal_angle.py)"))
|
||
|
||
def set_entry_room(self, x: float):
|
||
with self._lock:
|
||
self._entry_room_atr = max(0.0, float(x))
|
||
log.info(f"Entry-Raum-Gate {self._entry_room_atr:.2f}×ATR "
|
||
f"({'aus' if self._entry_room_atr <= 0 else 'aktiv'})")
|
||
|
||
def set_breakout_k(self, k: float):
|
||
with self._lock:
|
||
self._breakout_k = max(0.0, float(k))
|
||
self._pend = None
|
||
log.info(f"Breakout-Bestätigung k={self._breakout_k:.2f}×ATR "
|
||
f"({'aus' if self._breakout_k <= 0 else 'aktiv'})")
|
||
|
||
def set_dead_hours(self, hours):
|
||
"""Tageszeit-Gate setzen (Set/Liste von Stunden 0–23). Leer/None = AUS.
|
||
Default (Code) = gemessene Negativ-Stunden; per `[trading] dead_hours` steuerbar."""
|
||
try:
|
||
hs = {int(h) for h in (hours or []) if 0 <= int(h) <= 23}
|
||
except (TypeError, ValueError):
|
||
hs = set(_DEAD_HOURS)
|
||
with self._lock:
|
||
self._dead_hours = hs
|
||
log.info(f"Tageszeit-Gate: {sorted(hs) if hs else 'AUS'}")
|
||
|
||
def set_sr_context(self, resistance, support):
|
||
"""Vom Engine: nächste S/R-Linien über/unter dem aktuellen Preis."""
|
||
with self._lock:
|
||
self._sr_res = resistance
|
||
self._sr_sup = support
|
||
|
||
def set_timeframe(self, tf: int):
|
||
with self._lock:
|
||
self._tf = tf
|
||
self._rec = None # alte Welle verwerfen — TF gewechselt
|
||
self._ts = 0.0
|
||
# ⚠ `_pend` wird BEWUSST NICHT MEHR zurückgesetzt (Fix 2026-07-31).
|
||
# Vorher: `self._pend = None`. Real beobachtet an der Rally vom 31.07.
|
||
# (82,5 → 84,4 in 2 h): die TF-Heuristik wechselte 33× am Tag (Median-
|
||
# Abstand 5 min), weil der Score einer TF hart auf 0,0 fällt, sobald sie
|
||
# überdehnt ist (`_choose_tf`: `raw = sep if … stretch <= _STRETCH_MAX
|
||
# else 0.0`) — genau das passiert M5 IM Trend. Jeder Wechsel verwarf die
|
||
# laufende Breakout-Bestätigung und verankerte sie beim AKTUELLEN Kurs neu.
|
||
# Bei k=0,3 und ATR_M30≈0,53 braucht sie ~0,16 $; der Kurs lief ~0,08 $
|
||
# je 5 min → die Bestätigung konnte RECHNERISCH nie fertig werden. Folge:
|
||
# live 93 % WARTEN gegen ~43 % Backtest-Erwartung (`backtest_dist.py`).
|
||
# Der Anker gehört zum SIGNAL, nicht zur Zeitebene; dreht die Richtung,
|
||
# verankert `_confirm_breakout` ohnehin neu (`p.get("dir") != d`).
|
||
# ⚠ Das Level wurde mit dem ATR der alten TF gesetzt — bewusst behalten:
|
||
# eine Neuberechnung wäre wieder ein Reset und damit derselbe Fehler.
|
||
log.info(f"Wellen-TF: {_TF_LABELS.get(tf, str(tf))}")
|
||
|
||
# ── TU als Konfidenz-Faktor (kein harter Blocker mehr) ───────────────────
|
||
def _tu_check(self, direction: str) -> tuple[bool, int, list[str]]:
|
||
"""Rückgabe (immer True, kein Block; Bonus/Abzug; Gründe). TU bestätigt
|
||
die EMA-Richtung → Bonus, steht dagegen → Abzug — pro TF (5m/15m)."""
|
||
snap = self.tu.snapshot()
|
||
iv = snap.get("intervals") or {}
|
||
s5 = iv.get("5m", {}).get("score")
|
||
s15 = iv.get("15m", {}).get("score")
|
||
if s5 is None or s15 is None:
|
||
return True, 0, ["TU: keine Daten"]
|
||
d = 1 if direction == "LONG" else -1
|
||
bonus, reasons = 0, []
|
||
for lbl, s in (("5m", s5), ("15m", s15)):
|
||
if s * d >= 1:
|
||
bonus += _TU_BONUS; reasons.append(f"TU {lbl} dafür")
|
||
elif s * d <= -1:
|
||
bonus -= _TU_PENALTY; reasons.append(f"TU {lbl} dagegen")
|
||
if not reasons:
|
||
reasons.append("TU neutral")
|
||
return True, bonus, reasons
|
||
|
||
# ── Marktdaten-Refresh (Hintergrund-Thread, ~5 s) ────────────────────────
|
||
def refresh_market(self, sym: str):
|
||
with self._lock:
|
||
tf = self._tf
|
||
with mt5_lock(timeout=2) as got:
|
||
if not got:
|
||
return
|
||
bars = mt5.copy_rates_from_pos(sym, tf, 0, _N_BARS)
|
||
# Higher-TF-Trend (M30) für den Gegen-Trend-Filter — nur für
|
||
# niedrigere Basis-TFs (M30/H1 filtern sich sonst selbst).
|
||
hbars = None
|
||
if tf not in (_HTF, mt5.TIMEFRAME_H1):
|
||
hbars = mt5.copy_rates_from_pos(sym, _HTF, 0, _N_BARS)
|
||
# H1-Trend zusätzlich für die Multi-TF-Konfluenz (nur Konfidenz).
|
||
h1bars = None
|
||
if tf != mt5.TIMEFRAME_H1:
|
||
h1bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_H1, 0, _N_BARS)
|
||
# Wende-Erkennung je TF (M1/M5/M15/M30) für die Welle-Ampel:
|
||
# EMA12/50-Kreuzung mit Totband. Gedrosselt (~15 s) — die Ampel
|
||
# braucht keine 5-s-Granularität, spart ~⅓ der Lock-Haltezeit.
|
||
turn_data: dict = {}
|
||
if time.time() >= self._turn_due:
|
||
for _lbl, _t in (("M1", mt5.TIMEFRAME_M1), ("M5", mt5.TIMEFRAME_M5),
|
||
("M15", mt5.TIMEFRAME_M15), ("M30", mt5.TIMEFRAME_M30),
|
||
("H1", mt5.TIMEFRAME_H1)):
|
||
# M5 tiefer holen (320): daraus werden die P(break)-Pivot-Level
|
||
# berechnet (Training: rohe M5-Pivots k=3, Lookback 300 Bars).
|
||
_n = 320 if _lbl == "M5" else 120
|
||
_tb = mt5.copy_rates_from_pos(sym, _t, 0, _n)
|
||
if _tb is not None and len(_tb) > _ATR_PERIOD:
|
||
_c = [float(b["close"]) for b in _tb]
|
||
_h = [float(b["high"]) for b in _tb]
|
||
_l = [float(b["low"]) for b in _tb]
|
||
turn_data[_lbl] = (_c, _atr(_h, _l, _c))
|
||
if _lbl == "M5":
|
||
self._m5_hl = (_h, _l)
|
||
elif _lbl == "M30":
|
||
# Bar-ZEITEN mit ablegen — die Liquiditätszonen (Equal
|
||
# Highs/Lows) brauchen den Zeitpunkt der beiden Pivots,
|
||
# um im Chart als Band gezeichnet werden zu können.
|
||
self._m30_t = [int(b["time"]) for b in _tb]
|
||
# M30-H/L für die S/R-LEVEL (Chart + P(break)-Auto-Close).
|
||
# Gemessen 2026-07-30 (`backtest_level_tf.py`): M30-Level
|
||
# schlagen M5 in BEIDEN Hälften (R-Ertrag ~2×, Level 1,95
|
||
# statt 0,61×ATR entfernt). Das M5-`_pb_levels` bleibt
|
||
# daneben bestehen — es speist das Entry-Raum-Gate, das
|
||
# auf M5 kalibriert ist (nicht ungemessen mitverändern!).
|
||
self._m30_hl = (_h, _l)
|
||
self._turn_due = time.time() + 15
|
||
# ── Schneller M5-Squeeze-Fetch (Variante A): entkoppelt von der 15-s-
|
||
# Ampel, kleiner 60-Bar-M5-Fetch ~alle 5 s → Ausbrüche werden ~3× schneller
|
||
# erkannt (gleiche validierte M5-Regel). Squeeze wird danach neu berechnet.
|
||
sq_m5 = None
|
||
if time.time() >= self._sq_due:
|
||
_sb = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, 60)
|
||
if _sb is not None and len(_sb) > _ATR_PERIOD:
|
||
sq_m5 = ([float(b["high"]) for b in _sb],
|
||
[float(b["low"]) for b in _sb],
|
||
[float(b["close"]) for b in _sb])
|
||
self._sq_due = time.time() + _SQ_REFRESH_S
|
||
if bars is None or len(bars) < _ATR_PERIOD + 5:
|
||
with self._lock:
|
||
self._error = "keine Bars"
|
||
return
|
||
|
||
highs = [float(b["high"]) for b in bars]
|
||
lows = [float(b["low"]) for b in bars]
|
||
closes = [float(b["close"]) for b in bars]
|
||
atr = _atr(highs, lows, closes)
|
||
if not atr or atr <= 0:
|
||
with self._lock:
|
||
self._error = "ATR=0"
|
||
return
|
||
|
||
cur = closes[-1]
|
||
tf_lbl = _TF_LABELS.get(tf, str(tf))
|
||
ef = _ema_last(closes, _EMA_FAST)
|
||
es = _ema_last(closes, _EMA_SLOW)
|
||
with self._lock:
|
||
sr_res, sr_sup = self._sr_res, self._sr_sup
|
||
htf_trend = self._htf_sign(hbars)
|
||
h1_trend = self._htf_sign(h1bars)
|
||
angle = calc_trend_angle(closes[-(_ANGLE_LR + 2):], _ANGLE_LR)
|
||
hour = datetime.now(_BERLIN).hour
|
||
|
||
rec, snap = self._build(ef, es, cur, atr, tf_lbl, 0,
|
||
sr_res, sr_sup, htf_trend=htf_trend,
|
||
h1_trend=h1_trend, angle=angle, hour=hour)
|
||
# Die tatsächlich genutzten Higher-TF-Signale für die Gesamtempfehlung
|
||
# exponieren (konsistent mit der Empfehlung, nicht mit dem Winkel-Maß).
|
||
snap["htf_trend"] = htf_trend
|
||
snap["h1_trend"] = h1_trend
|
||
# Squeeze aus dem schnellen (~5 s) M5-Fetch neu berechnen — entkoppelt von der
|
||
# 15-s-Ampel (Variante A). Gleiche validierte M5-Regel, nur öfter geprüft.
|
||
if sq_m5:
|
||
_h5s, _l5s, _c5s = sq_m5
|
||
_a5s = _atr(_h5s, _l5s, _c5s)
|
||
if _a5s and _a5s > 0:
|
||
self._squeeze = self._squeeze_one(_h5s, _l5s, _c5s, max(_a5s, 0.12))
|
||
if turn_data: # frisch geholt → neu berechnen, sonst letzten Stand halten
|
||
self._last_tf_turns = {lbl: self._turn_state(c, a)
|
||
for lbl, (c, a) in turn_data.items()}
|
||
# P(break)-Features IMMER aus M5 (Modell wurde auf M5 trainiert/kalibriert
|
||
# — die Wave-TF wechselt per Heuristik bis M30; damit wäre mom6 ein 3-h-
|
||
# statt 30-min-Momentum und der ATR 2–3× größer → P(break) völlig falsch
|
||
# skaliert. Bug gefixt 2026-07-13.) ATR-Floor 0,12 wie im Training.
|
||
if "M5" in turn_data:
|
||
_c5, _a5 = turn_data["M5"]
|
||
if _a5 and _a5 > 0 and len(_c5) >= _EMA_SLOW + 2:
|
||
_a5f = max(_a5, 0.12)
|
||
self._pb_feats = {
|
||
"mom6": round((_c5[-1] - _c5[-7]) / _a5f, 3),
|
||
"mom3": round((_c5[-1] - _c5[-4]) / _a5f, 3),
|
||
"ema_diff": round((_ema_last(_c5, _EMA_FAST)
|
||
- _ema_last(_c5, _EMA_SLOW)) / _a5f, 3),
|
||
"atr": round(_a5f, 4),
|
||
}
|
||
# Pivot-Level trainingsgleich: rohe M5-Pivots (k=3) über die
|
||
# letzten ~300 Bars — NICHT die geclusterten M15-Level (die sind
|
||
# in frischen Trends oft LEER → Hint verschwand; Bugfix).
|
||
_h5, _l5 = getattr(self, "_m5_hl", (None, None))
|
||
if _h5 and len(_h5) >= 10:
|
||
_K = 3
|
||
ph = [round(_h5[j], 3) for j in range(_K, len(_h5) - _K)
|
||
if _h5[j] == max(_h5[j - _K:j + _K + 1])]
|
||
pl = [round(_l5[j], 3) for j in range(_K, len(_l5) - _K)
|
||
if _l5[j] == min(_l5[j - _K:j + _K + 1])]
|
||
self._pb_levels = {"ph": ph, "pl": pl}
|
||
# (Squeeze läuft jetzt auf dem schnellen ~5-s-M5-Fetch oben,
|
||
# nicht mehr hier auf dem 15-s-Ampel-Pfad — Variante A 2026-07-21.)
|
||
# ── S/R-LEVEL auf M30 (gemessen 2026-07-30, `backtest_level_tf.py`) ──
|
||
# Trainingsgleiches Fenster: der M5-Lookback von 300 Bars entspricht
|
||
# 50 M30-Bars → nur die letzten 50+K betrachten, sonst kämen Level aus
|
||
# einem viel längeren Zeitraum als im Training.
|
||
_h30, _l30 = getattr(self, "_m30_hl", (None, None))
|
||
if _h30 and len(_h30) >= 12:
|
||
_K = 3; _WIN = 50 + _K
|
||
_hs = _h30[-_WIN:]; _ls = _l30[-_WIN:]
|
||
ph30 = [round(_hs[j], 3) for j in range(_K, len(_hs) - _K)
|
||
if _hs[j] == max(_hs[j - _K:j + _K + 1])]
|
||
pl30 = [round(_ls[j], 3) for j in range(_K, len(_ls) - _K)
|
||
if _ls[j] == min(_ls[j - _K:j + _K + 1])]
|
||
self._pb_levels30 = {"ph": ph30, "pl": pl30}
|
||
# ── Liquiditätszonen: EQUAL HIGHS / EQUAL LOWS (SMC, User-Wunsch
|
||
# 2026-07-30 „zeichne mir die Liquiditätszonen wie im Bild ein").
|
||
# Zwei ~gleiche M30-Pivots (innerhalb _EQ_TOL×ATR, 2–40 Bars
|
||
# auseinander) = Stop-Cluster. ⚠ REINE ANZEIGE — als SIGNAL gemessen
|
||
# und verworfen (`backtest_liquidity_sweep.py`: Equal Highs/Lows
|
||
# fallen in fast allen Varianten durch, der SMC-Exit sogar überall).
|
||
_a30 = turn_data.get("M30", (None, None))[1] if turn_data else None
|
||
_t30 = getattr(self, "_m30_t", None)
|
||
if _a30 and _a30 > 0 and _t30 and len(_t30) >= len(_hs):
|
||
_tol = _EQ_TOL_ATR * _a30
|
||
_toff = len(_t30) - len(_hs) # Index-Versatz Fenster→Voll-Liste
|
||
def _equal(vals, want_high):
|
||
# jüngstes Paar zuerst suchen (rückwärts)
|
||
idx = [j for j in range(_K, len(vals) - _K)
|
||
if ((vals[j] == max(vals[j - _K:j + _K + 1])) if want_high
|
||
else (vals[j] == min(vals[j - _K:j + _K + 1])))]
|
||
for a in range(len(idx) - 1, 0, -1):
|
||
for b in range(a - 1, -1, -1):
|
||
gap = idx[a] - idx[b]
|
||
if gap < 2: continue
|
||
if gap > 40: break
|
||
if abs(vals[idx[a]] - vals[idx[b]]) <= _tol:
|
||
lo = min(vals[idx[a]], vals[idx[b]])
|
||
hi = max(vals[idx[a]], vals[idx[b]])
|
||
pad = max(0.0, (_EQ_MIN_H_ATR * _a30 - (hi - lo)) / 2)
|
||
return {"lo": round(lo - pad, 3), "hi": round(hi + pad, 3),
|
||
"t": int(_t30[idx[b] + _toff])}
|
||
return None
|
||
self._liq_zones = {"eqh": _equal(_hs, True), "eql": _equal(_ls, False)}
|
||
# Multi-TF-Bounce: stärksten Status über M1/M5/M15/M30 wählen (H1 ist NUR
|
||
# für die Ampel im turn_data, NICHT im gemessen-neutralen Bounce → skip).
|
||
cands = []
|
||
for lbl, (c, a) in turn_data.items():
|
||
if lbl == "H1":
|
||
continue
|
||
b = self._bounce_one(c, a)
|
||
if b:
|
||
b["tf"] = lbl; cands.append(b)
|
||
if cands:
|
||
rank = {"active": 2, "expected": 1}
|
||
best = max(cands, key=lambda b: (rank[b["state"]], b["stretch"]))
|
||
self._last_bounce = {"state": best["state"], "dir": best["dir"], "tf": best["tf"]}
|
||
else:
|
||
self._last_bounce = {"state": None, "dir": None, "tf": None}
|
||
snap["tf_turns"] = dict(self._last_tf_turns)
|
||
snap["bounce"] = dict(self._last_bounce) # Multi-TF überschreibt Basis-TF
|
||
snap["pb_feats"] = dict(self._pb_feats) if getattr(self, "_pb_feats", None) else None
|
||
snap["pb_levels"] = dict(self._pb_levels) if getattr(self, "_pb_levels", None) else None
|
||
# M30-Level = Quelle für Chart-Linien UND P(break)-Auto-Close (gemessen besser);
|
||
# `pb_levels` (M5) bleibt für das Entry-Raum-Gate.
|
||
snap["pb_levels30"] = dict(self._pb_levels30) if getattr(self, "_pb_levels30", None) else None
|
||
# Liquiditätszonen (Equal Highs/Lows, M30) — REINE ANZEIGE (Signal verworfen)
|
||
snap["liq_zones"] = dict(self._liq_zones) if getattr(self, "_liq_zones", None) else None
|
||
snap["squeeze"] = dict(self._squeeze) if getattr(self, "_squeeze", None) else \
|
||
{"state": None, "dir": None, "level": None, "box_atr": None}
|
||
# ── Entry-Raum-Gate (gemessen `backtest_entryroom.py`, beide Hälften
|
||
# monoton): Richtungssignal → WARTEN, wenn das GEGENLEVEL (M5-Pivot in
|
||
# Trade-Richtung, trainingsgleich zum S/R-Auto-Close) näher als
|
||
# `_entry_room_atr`×ATR_M5 liegt — der Ertrag ist dort durch den Auto-
|
||
# Close gedeckelt (63 % Containment), die Echtkosten fressen den Rest.
|
||
# Verhindert die Sofort-/Klein-Close-Trades an der QUELLE (Exit bleibt).
|
||
rec = self._room_gate(rec, cur)
|
||
rec = self._confirm_breakout(rec, cur, atr, snap) # k×ATR-Bestätigung
|
||
with self._lock:
|
||
self._rec = rec
|
||
self._snap = snap
|
||
self._ts = time.time()
|
||
self._error = None
|
||
|
||
@staticmethod
|
||
def _htf_sign(hbars) -> int:
|
||
"""Vorzeichen des Higher-TF-Trends (M30, EMA12 vs EMA50): +1 auf,
|
||
−1 ab, 0 neutral/keine Daten (Totband _HTF_DEADBAND×ATR). Fail-open:
|
||
ohne Bars → 0 → kein Filter."""
|
||
if hbars is None or len(hbars) < _EMA_SLOW + 5:
|
||
return 0
|
||
h = [float(b["high"]) for b in hbars]
|
||
l = [float(b["low"]) for b in hbars]
|
||
c = [float(b["close"]) for b in hbars]
|
||
atr = _atr(h, l, c)
|
||
if not atr or atr <= 0:
|
||
return 0
|
||
ef = _ema_last(c, _EMA_FAST)
|
||
es = _ema_last(c, _EMA_SLOW)
|
||
if ef is None or es is None:
|
||
return 0
|
||
d = ef - es
|
||
if abs(d) < _HTF_DEADBAND * atr:
|
||
return 0
|
||
return 1 if d > 0 else -1
|
||
|
||
@staticmethod
|
||
def _turn_state(closes, atr: float | None = None) -> dict:
|
||
"""Wende-Status einer TF aus der EMA12/50-Kreuzung — mit Totband, damit
|
||
bei seitwärts laufenden EMAs nicht jede Mikro-Kreuzung als „Wende" blinkt.
|
||
dir: +1 auf / −1 ab / 0 unklar (im Totband) · bars_ago: Bars seit letzter
|
||
Kreuzung (None = keine im Fenster) · fresh: echte Kreuzung ≤3 Bars her."""
|
||
if not closes or len(closes) < _EMA_SLOW + 5:
|
||
return {"dir": 0, "bars_ago": None, "fresh": False}
|
||
ef = _ema_series(closes, _EMA_FAST)
|
||
es = _ema_series(closes, _EMA_SLOW)
|
||
diff = [a - b for a, b in zip(ef, es)]
|
||
dead = (_TREND_DEADBAND * atr) if atr else 0.0 # Substanz-Schwelle
|
||
last = diff[-1]
|
||
cur = 1 if last > dead else -1 if last < -dead else 0
|
||
bars_ago = None
|
||
for i in range(len(diff) - 1, _EMA_SLOW, -1): # Warmup-Bereich überspringen
|
||
if diff[i] != 0 and (diff[i] > 0) != (diff[i - 1] > 0):
|
||
bars_ago = (len(diff) - 1) - i # Kreuzung bei Bar i
|
||
break
|
||
return {"dir": cur, "bars_ago": bars_ago,
|
||
"fresh": bars_ago is not None and bars_ago <= 3 and cur != 0}
|
||
|
||
def _confirm_breakout(self, rec, cur, atr, snap):
|
||
"""ATR-Breakout-Bestätigung (stateful): ein Richtungssignal wird erst
|
||
durchgelassen, wenn der Kurs **k×ATR in Signalrichtung** gelaufen ist
|
||
(= „X dynamisch"). Läuft er vorher k×ATR DAGEGEN oder Timeout → neu
|
||
verankern (WARTEN). Gemessen ~2× Edge/Trade (`backtest_breakout.py`).
|
||
k=0 → Filter aus (Sofort-Einstieg)."""
|
||
k = self._breakout_k
|
||
sig = rec.get("signal")
|
||
snap["breakout"] = {"pending": False, "dir": None, "need": None}
|
||
if not k or k <= 0 or atr <= 0 or sig == "WARTEN":
|
||
if sig == "WARTEN":
|
||
self._pend = None
|
||
return rec
|
||
d = 1 if sig == "LONG" else -1
|
||
p = self._pend
|
||
if p is None or p.get("dir") != d: # frisches Signal → verankern
|
||
p = {"dir": d, "level": cur + d * k * atr, "invalid": cur - d * k * atr,
|
||
"confirmed": False, "start": time.time()}
|
||
self._pend = p
|
||
if p["confirmed"]:
|
||
return rec # schon bestätigt → durchlassen
|
||
broke = (cur >= p["level"]) if d > 0 else (cur <= p["level"])
|
||
against = (cur <= p["invalid"]) if d > 0 else (cur >= p["invalid"])
|
||
if broke:
|
||
p["confirmed"] = True
|
||
return rec
|
||
if against or (time.time() - p["start"]) > self._breakout_timeout_s:
|
||
p = {"dir": d, "level": cur + d * k * atr, "invalid": cur - d * k * atr,
|
||
"confirmed": False, "start": time.time()} # neu verankern
|
||
self._pend = p
|
||
need = abs(p["level"] - cur)
|
||
snap["breakout"] = {"pending": True, "dir": sig,
|
||
"level": round(p["level"], 3), "need": round(need, 3)}
|
||
return {"signal": "WARTEN", "conf_pct": 0, "score": 0.0, "setup": "WAVE",
|
||
"regime": None, "rsi": None, "block": "breakout_pending",
|
||
"reasons": [f"warte auf Breakout (+{k:.1f}×ATR {sig}, noch {need:.2f})"]}
|
||
|
||
def _room_gate(self, rec, cur):
|
||
"""Entry-Raum-Gate: Signal → WARTEN, wenn das Gegenlevel < X×ATR_M5 entfernt
|
||
ist (gemessen `backtest_entryroom.py`: Raum <0,6×ATR in BEIDEN Hälften klar
|
||
negativ — 79–82 % WR, aber PF<1 = Klein-Close-Falle). Level/ATR trainings-
|
||
gleich aus `_pb_levels`/`_pb_feats` (M5). Fail-open: ohne Daten kein Gate."""
|
||
x = self._entry_room_atr
|
||
sig = rec.get("signal")
|
||
if x <= 0 or sig == "WARTEN":
|
||
return rec
|
||
lv = getattr(self, "_pb_levels", None) or {}
|
||
pf = getattr(self, "_pb_feats", None) or {}
|
||
atr5 = pf.get("atr")
|
||
if not atr5:
|
||
return rec
|
||
d = 1 if sig == "LONG" else -1
|
||
if d > 0:
|
||
cands = [p for p in (lv.get("ph") or []) if p > cur]
|
||
lvl = min(cands) if cands else None
|
||
else:
|
||
cands = [p for p in (lv.get("pl") or []) if p < cur]
|
||
lvl = max(cands) if cands else None
|
||
if lvl is None: # freie Bahn (bestes gemessenes Segment)
|
||
return rec
|
||
dist = (lvl - cur) * d / atr5
|
||
if dist >= x:
|
||
return rec
|
||
return {"signal": "WARTEN", "conf_pct": 0, "score": 0.0, "setup": "WAVE",
|
||
"regime": None, "rsi": None, "block": "entry_room",
|
||
"reasons": [f"kein Raum: {'Widerstand' if d > 0 else 'Support'} "
|
||
f"{lvl:.2f} nur {dist:.2f}×ATR entfernt (Gate {x:.1f}) — "
|
||
f"Ertrag gedeckelt, Kosten fressen den Edge"]}
|
||
|
||
@staticmethod
|
||
def _squeeze_one(highs, lows, closes, atr):
|
||
"""Volatilitäts-Squeeze-Breakout (M5, gemessen `backtest_breakout_squeeze.py`).
|
||
Box = Spanne der letzten _SQ_N ABGESCHLOSSENEN Bars. Ist sie ≤ _SQ_MULT×ATR
|
||
(Kompression):
|
||
- Kurs bricht _SQ_K×ATR über/unter die Box → `active` (LONG/SHORT), level =
|
||
Ausbruchsgrenze — das getestete Einstiegssignal.
|
||
- sonst → `armed` (komprimiert, Ausbruch steht bevor; dir = nähere Grenze).
|
||
Nicht komprimiert → state None. Rein transient (der Ausbruch weitet die Box →
|
||
Signal klärt sich von selbst, sobald der Move läuft)."""
|
||
none = {"state": None, "dir": None, "level": None, "box_atr": None}
|
||
if not atr or atr <= 0 or len(closes) < _SQ_N + 2:
|
||
return none
|
||
hb = highs[-_SQ_N - 1:-1]; lb = lows[-_SQ_N - 1:-1] # N abgeschlossene Bars
|
||
if not hb or not lb:
|
||
return none
|
||
box_hi = max(hb); box_lo = min(lb)
|
||
box_atr = round((box_hi - box_lo) / atr, 2)
|
||
# Box-Grenzen immer mitgeben — der MT5-Chart zeichnet die Konsolidierung
|
||
# daraus (reine Anzeige; Konsolidierungs-SETUPS sind gemessen verworfen,
|
||
# `backtest_consolidation.py` — nur der Squeeze-Ausbruch ist validiert).
|
||
_box = {"box_hi": round(box_hi, 3), "box_lo": round(box_lo, 3)}
|
||
if box_atr > _SQ_MULT: # keine Kompression
|
||
return {"state": None, "dir": None, "level": None, "box_atr": box_atr, **_box}
|
||
px = closes[-1]
|
||
up = box_hi + _SQ_K * atr; dn = box_lo - _SQ_K * atr
|
||
if px >= up:
|
||
return {"state": "active", "dir": "LONG", "level": round(up, 3), "box_atr": box_atr, **_box}
|
||
if px <= dn:
|
||
return {"state": "active", "dir": "SHORT", "level": round(dn, 3), "box_atr": box_atr, **_box}
|
||
dir_ = "LONG" if (up - px) <= (px - dn) else "SHORT" # armed → nähere Grenze
|
||
return {"state": "armed", "dir": dir_,
|
||
"level": round(up if dir_ == "LONG" else dn, 3), "box_atr": box_atr, **_box}
|
||
|
||
@staticmethod
|
||
def _bounce_one(closes, atr):
|
||
"""Bounce-Status EINER TF: überdehnt (|stretch|≥_REVERSAL_STRETCH von EMA50)
|
||
→ expected; + Winkel gedreht → active. dir=LONG bei überverkauft (unter EMA),
|
||
SHORT bei überkauft. None, wenn nicht überdehnt."""
|
||
if not closes or len(closes) < _EMA_SLOW + 5 or not atr or atr <= 0:
|
||
return None
|
||
es = _ema_series(closes, _EMA_SLOW)[-1]
|
||
stretch = (closes[-1] - es) / atr
|
||
if abs(stretch) < _REVERSAL_STRETCH:
|
||
return None
|
||
ad = calc_trend_angle(closes[-(_ANGLE_LR + 2):], _ANGLE_LR) - 90.0
|
||
turned = (stretch < 0 and ad >= _ANGLE_DEAD) or (stretch > 0 and ad <= -_ANGLE_DEAD)
|
||
return {"state": "active" if turned else "expected",
|
||
"dir": "LONG" if stretch < 0 else "SHORT", "stretch": abs(stretch)}
|
||
|
||
def _build(self, ef, es, cur, atr, tf_lbl, n_pivots,
|
||
sr_res=None, sr_sup=None, htf_trend=0, h1_trend=0, angle=90.0,
|
||
hour=None):
|
||
"""Signalrichtung aus dem EMA12/50-Trend; Einstieg nur, wenn der Trend
|
||
klar ist (Totband) und der Kurs nicht überdehnt von der EMA weg ist."""
|
||
diff = (ef - es) if (ef is not None and es is not None) else 0.0
|
||
sep = diff / atr if atr else 0.0 # Trendstärke in ATR
|
||
stretch = (cur - es) / atr if (es is not None and atr) else 0.0
|
||
snap = {"tf": tf_lbl, "atr": atr,
|
||
"direction": ("up" if diff > 0 else "down" if diff < 0 else None),
|
||
"wave_start": round(es, 3) if es is not None else None,
|
||
"move_atr": round(stretch, 2), "n_pivots": n_pivots,
|
||
"trend_sep": round(sep, 2)}
|
||
# ── Bounce-Status (für die Anzeige), unabhängig vom Signal-Flow:
|
||
# "expected" = überdehnt (≥_REVERSAL_STRETCH), Winkel noch NICHT gedreht
|
||
# "active" = überdehnt UND Winkel gedreht (= der Reversal-Trigger feuert)
|
||
# dir = LONG bei überverkauft (unter EMA), SHORT bei überkauft.
|
||
_ad = angle - 90.0
|
||
if abs(stretch) >= _REVERSAL_STRETCH:
|
||
_turned = (stretch < 0 and _ad >= _ANGLE_DEAD) or (stretch > 0 and _ad <= -_ANGLE_DEAD)
|
||
snap["bounce"] = {"state": "active" if _turned else "expected",
|
||
"dir": "LONG" if stretch < 0 else "SHORT"}
|
||
else:
|
||
snap["bounce"] = {"state": None, "dir": None}
|
||
# `block` = maschinenlesbarer Grund, WARUM WARTEN (Stufe-2-Telemetrie
|
||
# 2026-07-31). Die `reasons` sind für Menschen, `block` fuer die Statistik:
|
||
# ohne ihn war live nicht feststellbar, welches der Gates die 93 % WARTEN
|
||
# erzeugt (Backtest-Erwartung ~43 %).
|
||
wait = {"signal": "WARTEN", "conf_pct": 0, "score": 0.0, "block": None,
|
||
"setup": "WAVE", "regime": None, "rsi": None, "reasons": []}
|
||
|
||
# Totband: kein klarer Trend → kein Trade (Chop)
|
||
if abs(sep) < _TREND_DEADBAND:
|
||
wait["block"] = "deadband"
|
||
wait["reasons"] = [f"kein klarer Trend ({tf_lbl}, EMA-Abstand {sep:+.2f}×ATR)"]
|
||
return wait, snap
|
||
|
||
# Tageszeit-Gate (per Config `set_dead_hours`; Default _DEAD_HOURS = Nacht-
|
||
# Kostenfalle + 12/16 Uhr. Leer = AUS, User-Vorgabe 2026-07-22 trotz Messung).
|
||
if hour is not None and hour in self._dead_hours:
|
||
why = "Nacht-Spread frisst den Edge" if hour <= 7 else "gemessen negativer Edge"
|
||
wait["block"] = "dead_hour"
|
||
wait["reasons"] = [f"Zeit-Gate {hour}:00 Uhr — {why}, kein Trade"]
|
||
return wait, snap
|
||
|
||
# EIA-Blackout (gemessen, `backtest_events.py`): Mittwoch 15:30–16:30 Berlin
|
||
# = Vorlauf der EIA-Lagerdaten (16:30). In BEIDEN History-Hälften netto
|
||
# negativ (−0,147/−0,088 vs rest) — Positionierungs-Chop vor den Zahlen.
|
||
# Kausales Event-Fenster, kein Pauschal-Gate. Nur im Live-Pfad (hour≠None).
|
||
if hour is not None:
|
||
_now_b = datetime.now(_BERLIN)
|
||
if _now_b.weekday() == 2 and (15, 30) <= (_now_b.hour, _now_b.minute) < (16, 30):
|
||
wait["block"] = "eia"
|
||
wait["reasons"] = ["EIA-Blackout Mi 15:30–16:30 — Lagerdaten-Vorlauf, "
|
||
"gemessen negativ, kein Trade"]
|
||
return wait, snap
|
||
|
||
sig = "LONG" if diff > 0 else "SHORT"
|
||
reversal = False
|
||
|
||
# ── Reversal/BOUNCE (antizyklisch, markiertes ZWEITsignal): überdehnt
|
||
# (|stretch|≥_REVERSAL_STRETCH=3,0) + Regressions-Winkel hat GEDREHT →
|
||
# Einstieg in Winkel-Richtung, gegen die EMA. Exit-Sim (backtest_bounce.py):
|
||
# Ø-R +0,185, PF 1,35, Treffer 70 %, Worst −2×ATR (SL-gedeckelt) — profitabel,
|
||
# aber schwächer als Trend & regime-anfällig (blutet in starken Trends).
|
||
# Hebt Anti-Überdehnung UND M30-Filter bewusst auf (per Definition gegen
|
||
# die nachlaufende EMA/HTF).
|
||
# ⚠⚠ ABGESCHALTET über `[trading] reversal_enabled=false` (2026-08-04).
|
||
# Gemessen (`backtest_reversal_angle.py`, 80k M5, kanonischer Exit,
|
||
# Echtkosten, 2 Halbjahre) ist der Trigger in JEDER Variante beidhälftig
|
||
# negativ: IST −0,122/−0,031 · vorzeichenkorrigiert −0,173/−0,061 ·
|
||
# ohne Winkelbedingung −0,099/−0,023. Der Modul-Default bleibt True,
|
||
# damit die Backtests unverändert weiterlaufen.
|
||
# ⚠ ZUR BEDINGUNG SELBST (nicht korrigiert, weil gemessen SCHLECHTER):
|
||
# `calc_trend_angle` liefert 0°=AUFWÄRTS … 180°=ABWÄRTS, also ist
|
||
# `ad = angle−90` NEGATIV bei STEIGENDEM Kurs. Die untenstehenden
|
||
# Kommentare („Winkel auf/ab") beschreiben damit das GEGENTEIL dessen,
|
||
# was der Code tut: SHORT feuert bei überkauft UND WEITER STEIGENDEM
|
||
# Winkel. Das Vorzeichen wurde bewusst NICHT gedreht — die korrigierte
|
||
# Variante misst sich schlechter, und alle Alt-Messungen beruhen auf
|
||
# diesem Verhalten.
|
||
ad = angle - 90.0
|
||
if not self._reversal_enabled:
|
||
pass # Setup aus → nur Trendsignal
|
||
elif stretch <= -_REVERSAL_STRETCH and ad >= _ANGLE_DEAD:
|
||
sig, reversal = "LONG", True # ⚠ faktisch: Winkel FÄLLT noch
|
||
elif stretch >= _REVERSAL_STRETCH and ad <= -_ANGLE_DEAD:
|
||
sig, reversal = "SHORT", True # ⚠ faktisch: Winkel STEIGT noch
|
||
|
||
d_sig = 1 if sig == "LONG" else -1
|
||
|
||
if not reversal:
|
||
# Higher-TF-Gegen-Trend-Filter: kein Short im M30-Aufwärtstrend (und
|
||
# umgekehrt). Per Backtest belegt (Ø-Edge ×2). Nur reguläre Trendsignale.
|
||
if htf_trend != 0 and htf_trend != d_sig:
|
||
wait["block"] = "htf_counter"
|
||
wait["reasons"] = [
|
||
f"gegen {_HTF_LABEL}-Trend "
|
||
f"({'auf' if htf_trend > 0 else 'ab'}) — kein Gegen-Trade"]
|
||
return wait, snap
|
||
# Anti-Überdehnung: nicht weit weg von der EMA hinterherkaufen/-shorten
|
||
if sig == "LONG" and stretch > _STRETCH_MAX:
|
||
wait["block"] = "stretch"
|
||
wait["reasons"] = [f"überdehnt: {stretch:+.1f}×ATR über EMA — kein Spät-Long"]
|
||
return wait, snap
|
||
if sig == "SHORT" and stretch < -_STRETCH_MAX:
|
||
wait["block"] = "stretch"
|
||
wait["reasons"] = [f"überdehnt: {stretch:+.1f}×ATR unter EMA — kein Spät-Short"]
|
||
return wait, snap
|
||
|
||
if reversal:
|
||
reasons = [f"🔄 Reversal {sig}: {abs(stretch):.1f}×ATR überdehnt + Winkel gedreht"]
|
||
else:
|
||
reasons = [f"{tf_lbl}-Trend {'auf' if sig == 'LONG' else 'ab'} "
|
||
f"(EMA-Abstand {sep:+.2f}×ATR)"]
|
||
conf = _BASE_CONF
|
||
# Trendstärke
|
||
if abs(sep) >= 0.5:
|
||
conf += 10; reasons.append("starker Trend")
|
||
elif abs(sep) >= 0.25:
|
||
conf += 5
|
||
# Einstiegsqualität nach gemessenem Edge (backtest_pullback.py): ein
|
||
# TIEFER Pullback (Kurs durch die EMA zurück, near<0) trägt mit Abstand
|
||
# am besten; die laue Zone direkt an der EMA (0–0,3) ist der schwächste
|
||
# Edge; weit gelaufenes Trend-Momentum (1–2×ATR) trägt wieder ordentlich.
|
||
# Beeinflusst nur Konfidenz/Score (Anzeige + Auto-Dry-Run), NICHT die
|
||
# Signalrichtung.
|
||
near = stretch if sig == "LONG" else -stretch
|
||
if near < 0:
|
||
conf += 15; reasons.append("⭐ tiefer Pullback (bestes CRV)")
|
||
elif near < 0.3:
|
||
conf -= 3; reasons.append("laue Zone an EMA (schwächster Edge)")
|
||
elif near < 1.0:
|
||
conf += 3
|
||
elif near < 2.0:
|
||
conf += 8; reasons.append("Trend-Momentum trägt")
|
||
else:
|
||
conf += 5; reasons.append("weit gelaufen — Vorsicht Überdehnung")
|
||
|
||
# ── Multi-TF-Konfluenz (nur Konfidenz): M30 UND H1 dafür = Top-Setup;
|
||
# H1 dagegen = schwächer. M30-Gegen-Trend ist oben schon WARTEN, hier
|
||
# ist htf_trend also nur =Richtung oder neutral. (Backtest: Edge ×3,5.)
|
||
if htf_trend == d_sig and h1_trend == d_sig:
|
||
conf += _CONFLUENCE_BONUS
|
||
reasons.append("⭐⭐ Konfluenz M30+H1")
|
||
elif h1_trend != 0 and h1_trend != d_sig:
|
||
conf -= _H1_AGAINST_PEN
|
||
reasons.append("H1 gegen Richtung — schwächeres Setup")
|
||
|
||
# ── Kurzfrist-Winkel als PULLBACK-Maß (nur Konfidenz) ────────────────
|
||
# ⚠⚠ BESCHRIFTUNG KORRIGIERT 2026-08-04 — die ZAHLEN sind unverändert.
|
||
# `calc_trend_angle` liefert 0°=AUFWÄRTS … 180°=ABWÄRTS, also ist
|
||
# `ad = angle−90` NEGATIV bei steigendem Kurs. Die alten Texte
|
||
# („Winkel bestätigt" / „⚠ Winkel gegen EMA") sagten deshalb genau das
|
||
# GEGENTEIL dessen, was hier passiert — und `backtest_angle.py` benutzt
|
||
# dieselbe verdrehte Beschriftung („Winkel klar dagegen" = `a5 < 90-dead`
|
||
# = in Wahrheit STEIGEND). Der gemessene Befund lautet richtig gelesen:
|
||
# LONG bei FALLENDEM/flachem Kurzfrist-Winkel (= Pullback) → ØR +0,055
|
||
# LONG bei bereits STEIGENDEM Winkel (kein Pullback) → ØR +0,025
|
||
# Das ist der im Projekt mehrfach belegte Pullback-Effekt (vgl. „⭐ tiefer
|
||
# Pullback" und den RSI-Befund: bestätigende Oszillatoren sind schlechter).
|
||
# Die Gewichtung war also von Anfang an numerisch RICHTIG; nur hieß der
|
||
# Bonus „bestätigt", obwohl er den Gegen-Winkel belohnt. NICHT umdrehen.
|
||
if (d_sig > 0 and ad < -_ANGLE_DEAD) or (d_sig < 0 and ad > _ANGLE_DEAD):
|
||
conf -= _ANGLE_PENALTY
|
||
reasons.append("kein Pullback — Kurs läuft dem Signal schon davon")
|
||
elif (d_sig > 0 and ad > _ANGLE_DEAD) or (d_sig < 0 and ad < -_ANGLE_DEAD):
|
||
conf += _ANGLE_BONUS
|
||
reasons.append("⭐ Pullback (Kurzfrist-Winkel gegen den Trend)")
|
||
|
||
# Hohe Vola dämpfen (oberes ATR-Terzil = schwächster/negativer Edge) —
|
||
# nur Konfidenz (Schwelle regime-abhängig, kein hartes Gate).
|
||
if atr and atr >= _ATR_HIGH:
|
||
conf -= _ATR_HIGH_PEN
|
||
reasons.append("hohe Vola — schwächster Edge")
|
||
|
||
# ── S/R-Kontext: Raum bis zur nächsten Linie in Trade-Richtung ───────
|
||
if atr:
|
||
thr = _SR_NEAR_ATR * atr
|
||
if sig == "LONG":
|
||
if sr_res is not None and 0 <= (sr_res - cur) < thr:
|
||
conf -= _SR_PENALTY
|
||
reasons.append(f"dicht unter Widerstand {sr_res:.3f} (wenig Raum)")
|
||
if sr_sup is not None and 0 <= (cur - sr_sup) < thr:
|
||
conf += _SR_BONUS
|
||
reasons.append(f"an Unterstützung {sr_sup:.3f} (Rückenwind)")
|
||
else: # SHORT
|
||
if sr_sup is not None and 0 <= (cur - sr_sup) < thr:
|
||
conf -= _SR_PENALTY
|
||
reasons.append(f"dicht über Unterstützung {sr_sup:.3f} (wenig Raum)")
|
||
if sr_res is not None and 0 <= (sr_res - cur) < thr:
|
||
conf += _SR_BONUS
|
||
reasons.append(f"an Widerstand {sr_res:.3f} (Rückenwind)")
|
||
|
||
# ── Börsen-Session: Vorsicht nach Open, Bonus in aktiver Session ─────
|
||
ss = session_state()
|
||
if ss["just_opened"]:
|
||
conf -= _SESSION_CAUTION
|
||
reasons.append(f"{ss['just_opened']}-Open frisch — volatil, Vorsicht")
|
||
elif ss["active"]:
|
||
conf += _SESSION_BONUS
|
||
reasons.append(f"{'+'.join(ss['active'])}-Session aktiv")
|
||
elif not ss["weekend"]:
|
||
conf -= _SESSION_OFFHOURS
|
||
reasons.append("außerhalb DE/US-Session (dünn)")
|
||
|
||
# TU aus der Empfehlung ENTFERNT (User-Vorgabe 2026-07-06): Standard-Indikator-
|
||
# Konfluenz = kein Edge (gemessen `backtest_confluence.py`); TU lagt (5m „Strong
|
||
# Buy" während 4h/1d „Strong Sell") + ist nicht backtestbar (Live-Scrape, keine
|
||
# History). `_tu_check`/`_TU_*` bleiben als Code, fließen aber NICHT mehr in die
|
||
# Empfehlungs-Konfidenz. TU ist nur noch reine Anzeige (Snapshot).
|
||
conf = max(0, min(conf, 90))
|
||
|
||
# Mindest-Konfidenz-Gate: schwache Setups (zu viele Strafen gestapelt)
|
||
# tragen negativen Edge (gemessen Band 40–54 %) → kein Trade.
|
||
if conf < _MIN_CONF:
|
||
wait["block"] = "min_conf"
|
||
wait["reasons"] = [f"Konfidenz {conf}% < {_MIN_CONF}% — Setup zu schwach"] + reasons[:2]
|
||
return wait, snap
|
||
|
||
if reversal:
|
||
setup = "WAVE_REV_LONG" if sig == "LONG" else "WAVE_REV_SHORT"
|
||
else:
|
||
setup = "WAVE_LONG" if sig == "LONG" else "WAVE_SHORT"
|
||
rec = {"signal": sig, "conf_pct": conf,
|
||
"score": 0.8 if sig == "LONG" else -0.8,
|
||
"setup": setup, "regime": None, "rsi": None, "reasons": reasons}
|
||
return rec, snap
|
||
|
||
# ── Signal für Panel + Auto-Trader ───────────────────────────────────────
|
||
def signal(self) -> dict:
|
||
with self._lock:
|
||
rec, ts, err = self._rec, self._ts, self._error
|
||
if rec is None:
|
||
return {"signal": "WARTEN", "conf_pct": 0, "score": 0.0,
|
||
"setup": "WAVE", "regime": None, "rsi": None, "block": "no_data",
|
||
"reasons": [err or "keine Wellen-Daten"]}
|
||
if not ts or time.time() - ts > _STALE_S:
|
||
return {"signal": "WARTEN", "conf_pct": 0, "score": 0.0,
|
||
"setup": "WAVE", "regime": None, "rsi": None, "block": "stale",
|
||
"reasons": ["Wellen-Daten veraltet"]}
|
||
return dict(rec)
|
||
|
||
def snapshot(self) -> dict:
|
||
with self._lock:
|
||
d = dict(self._snap)
|
||
d["error"] = self._error
|
||
d["last_update"] = self._ts
|
||
return d
|