Files
AH-Oil-Trader/core/wave_rec.py
T
Axel HocksandClaude Opus 4.8 75d28827e8 Initial commit: Oil Trading Bot (MT5, WTI)
Headless FastAPI-Backend (server.py + core/engine.py) mit Mobile-PWA (web/),
Strategie-/Backtest-Suite und Doku. Secrets, DB, Logs und Laufzeit-State sind
via .gitignore ausgeschlossen; Config-Vorlage: oil_widget_config.ini.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 08:29:23 +02:00

787 lines
41 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"
_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"
_ANGLE_BONUS = 5 # Winkel bestätigt die EMA-Richtung
_ANGLE_PENALTY = 10 # Winkel klar GEGEN die EMA-Richtung (mögliche Wende)
# 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
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
self._pend = None # Breakout-Bestätigung zurücksetzen
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)
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.)
# 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
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,
"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,
"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)
if box_atr > _SQ_MULT: # keine Kompression
return {"state": None, "dir": None, "level": None, "box_atr": box_atr}
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}
if px <= dn:
return {"state": "active", "dir": "SHORT", "level": round(dn, 3), "box_atr": box_atr}
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}
@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}
wait = {"signal": "WARTEN", "conf_pct": 0, "score": 0.0,
"setup": "WAVE", "regime": None, "rsi": None, "reasons": []}
# Totband: kein klarer Trend → kein Trade (Chop)
if abs(sep) < _TREND_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["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["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).
ad = angle - 90.0
if stretch <= -_REVERSAL_STRETCH and ad >= _ANGLE_DEAD:
sig, reversal = "LONG", True # überverkauft + Winkel auf → Bounce
elif stretch >= _REVERSAL_STRETCH and ad <= -_ANGLE_DEAD:
sig, reversal = "SHORT", True # überkauft + Winkel ab → Bounce
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["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["reasons"] = [f"überdehnt: {stretch:+.1f}×ATR über EMA — kein Spät-Long"]
return wait, snap
if sig == "SHORT" and stretch < -_STRETCH_MAX:
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")
# ── Regressions-Winkel vs nachlaufende EMA (nur Konfidenz/Warnung) ───
# Steht der Winkel der Basis-TF klar GEGEN die EMA-Richtung, ist die EMA
# evtl. am Nachlaufen (Wende) → Warnung. Backtest: Winkel-dafür trägt
# klar besser; als Gate aber Gesamtertrag-negativ → nur Konfidenz.
# (ad = angle 90 wurde oben in der Reversal-Prüfung bereits berechnet.)
if (d_sig > 0 and ad < -_ANGLE_DEAD) or (d_sig < 0 and ad > _ANGLE_DEAD):
conf -= _ANGLE_PENALTY
reasons.append("⚠ Winkel gegen EMA (mögliche Wende)")
elif (d_sig > 0 and ad > _ANGLE_DEAD) or (d_sig < 0 and ad < -_ANGLE_DEAD):
conf += _ANGLE_BONUS
reasons.append("Winkel bestätigt")
# 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["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,
"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,
"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