Files
AH-Oil-Trader/core/wave_rec.py
T
Axel HocksandClaude Opus 5 90d77a2305 Pending-Pfad fuer SIG: ruhende Stop-Order am Bestaetigungs-Level
SIG laeuft damit auf der gemessen positiven Variante (OeR +0,35/+0,32, PF 2,2)
statt auf der Market-Order (-0,09/-0,11).

Der heikelste Punkt war nicht die Order, sondern die Kollision beider Pfade:
trader.pending_orders() liefert ALLE magic-gleichen Orders, zwei getrennte
Manager haetten sich gegenseitig storniert - sporadisch und im Log kaum
erkennbar. Deshalb EIN gemeinsamer Ziel-Zustand (engine._pending_ziel), der
Manager gleicht nur noch ab. Vorrang Squeeze > Signal (unabhaengig validiert,
selten; das Signal feuert dauernd, es gibt einen Positions-Slot).

Zwei additive Ergaenzungen in wave_rec._confirm_breakout: das
Bestaetigungs-Level wird auch NACH der Bestaetigung veroeffentlicht (die
Nachjagd-Bremse braucht es genau dann; pending bleibt False, Frontend
unberuehrt), und conf = die Konfidenz VOR dem Stummschalten - sonst muesste
die ruhende Order blind platziert werden.

Nachjagd-Bremse jetzt auch fuer SIG (0,20 xATR, Haertetest-gedeckt).
12 Szenarien getestet, live am Snapshot verifiziert. Snapshot-Felder
squeeze_pending_levels -> pending_levels + pending_quelle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:43:04 +02:00

937 lines
51 KiB
Python
Raw 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/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
|EMA12EMA50| < _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 4054 % = 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,53,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 1020 / k 0,10,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 # |EMA12EMA50| < 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):
# 07 Uhr = Nacht-Kostenfalle (konstanter Spread ÷ niedriger Nacht-ATR =
# 0,320,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 „07+12" verbesserte BEIDE Hälften (H1 4038→−1870, H2 +100→+1191).
# Robust positiv sind nur 2122 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 023). 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 23× 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, 240 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
# ⚠ Das Bestätigungs-Level wird AUCH nach der Bestätigung veröffentlicht
# (2026-08-05). Zwei Konsumenten brauchen es: die ruhende Stop-Order (sie
# liegt dort) und die Nachjagd-Bremse im Market-Fallback (sie misst den
# Abstand dorthin). `pending` bleibt False — das Frontend rendert nur bei
# `pending`, ist also unberührt.
if p["confirmed"]:
snap["breakout"] = {"pending": False, "dir": sig, "need": None,
"level": round(p["level"], 3), "confirmed": True}
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
snap["breakout"] = {"pending": False, "dir": sig, "need": None,
"level": round(p["level"], 3), "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)
# ⚠ `conf` = die Konfidenz VOR dem Stummschalten. Der Rückgabewert unten
# setzt `conf_pct` auf 0 (das Signal gilt ja noch nicht) — wer die ruhende
# Stop-Order gegen die Mindest-Konfidenz prüfen will, hätte sonst KEINE
# Zahl und müsste die Order blind platzieren.
snap["breakout"] = {"pending": True, "dir": sig,
"level": round(p["level"], 3), "need": round(need, 3),
"conf": rec.get("conf_pct") or 0, "confirmed": False}
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 — 7982 % 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:3016: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:3016: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 = angle90` 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 (00,3) ist der schwächste
# Edge; weit gelaufenes Trend-Momentum (12×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 = angle90` 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 4054 %) → 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