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>
This commit is contained in:
Axel Hocks
2026-07-24 08:29:23 +02:00
co-authored by Claude Opus 4.8
commit 75d28827e8
104 changed files with 21059 additions and 0 deletions
+786
View File
@@ -0,0 +1,786 @@
"""
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