Files
AH-Oil-Trader/backtest_auto_signal.py
Axel HocksandClaude Opus 4.8 2996eddd7e Auto-Signal-Entry + 15-Min-Regel (User-Wunsch, gegen die Messung, mit besten Params)
User will Auto-Eroeffnung bei Empfehlung trotz negativem Backtest -> gebaut,
Default AUS, mit den GEMESSEN besten Parametern statt geratenen:

Entry (_check_auto_signal, [trading] auto_signal=false):
- min_conf=75 + Nacht-Sperre = beste Kombi (H1 -0.053 mildester Verlust,
  H2 +0.141 bestes Netto). NICHT weil "mehr Konfidenz besser" -- conf_pct ist
  unkalibriert, 75 ist empirisch.
- Nur FLAT, 1x je Signal-Episode (Dedup, re-armt bei WARTEN/Flip), kein Nachkauf,
  kein Drehen einer Gegen-Position; erbt Circuit-Breaker + beide Cooldowns.
- Setup-Tag AUTOSIG_<dir> -> in der DB separierbar fuer B4-Monitor.
- Toggle POST /api/autosignal, Button "SIG" (bernstein = gemessen nicht tragfaehig),
  neustart-fest via runtime_state.json.

15-Min-Regel (_check_adverse15, adverse_15min_atr=0.5):
- Schwelle GEMESSEN kalibriert: "sobald im Minus" ist die SCHLECHTESTE Variante
  (WR 39->31%, Netto -0.067 vs +0.026 ohne); 0.5xATR ist die beste (H1 -0.111
  -> -0.079). Netto bringt die Regel ~nichts = Regime-Schutz.
- Nur BOT-eroeffnete Trades (_bot_open_ticket) -- manuelle Diskretion bleibt.
- 1x je Ticket geprueft, Startup-Grace respektiert, closed_by=adverse15.

Verifiziert: 18 synthetische Szenarien (9 Entry + 9 Adverse) alle korrekt;
Live-Neustart ohne Loop-Fehler, Toggle + Persistenz geprueft. v=119.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 14:01:14 +02:00

270 lines
12 KiB
Python
Raw Permalink 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.
#!/usr/bin/env python3
"""backtest_auto_signal.py — Auto-Entry auf die EMPFEHLUNG (B2, User-Wunsch
2026-07-30 „Trades bei Empfehlung automatisch eröffnen").
Kernfrage: Trägt ein AUTONOMER Entry auf dem Wave-Signal (mit den HEUTIGEN Gates)
netto in BEIDEN Halbjahren — und hält er gegen den einzigen validierten Auto-Entry
(Squeeze: ØR +0,14…+0,23, PF>1)?
Vorgeschichte (hohe Beweislast!): Der Wave-Auto-Trader lief live als Dry-Run und
war ein klarer VERLIERER (WR 29 % / PF 0,64 vs. manuell 5676 %) → am 2026-07-23
komplett entfernt. Gegenbefund: die MANUELLEN „MIT Signal"-Trades des Users sind
profitabel (+118 €, WR 56 %) — aber das ist MENSCHLICHE AUSWAHL unter den Signalen,
nicht „alle nehmen". Genau diese Differenz misst dieses Skript.
Methodik (bewusst pessimistisch, wie backtest_breakout_squeeze.py):
· Signal = die ECHTE Logik `WaveRecommender._build` (kein Nachbau), inkl.
Totband, Anti-Überdehnung, Konfidenz-Gate 55, Reversal-Zweig
· HTF = M30- UND H1-Trend aus den M5-Bars aggregiert und in `_build`
gefüttert (`htf_trend`/`h1_trend`) → der live aktive M30-Gegen-
Trend-Filter und der H1-Konfluenz-Bonus wirken mit
· Entry = am Bar-CLOSE des Signal-Bars, nur wenn FLAT (sequentiell, 1 Position)
· Exit = Live-Modell: SL 2,0×ATR · Trailing 1,5 ab 0,3 · Breakeven 1,3 ·
Time-Stop 120 min (24 M5-Bars) wenn nie Trail-Start erreicht
· Kosten = echter Bar-Spread/ATR (Ø ~0,265×ATR — NICHT die alte 0,1-Annahme)
· 2 Halbjahre (H1/H2) — eine Variante gilt nur als „trägt", wenn sie in BEIDEN
positiv ist (Dead-Hour-Lektion)
NICHT simuliert (bewusst): Entry-Raum-Gate + ATR-Breakout-Bestätigung (beide
brauchen Live-State/pb_levels, s. CLAUDE.md — Backtests bleiben gate-frei) sowie
das EIA-Blackout-Gate. Beide würden Trades ENTFERNEN, die Ergebnisse hier sind
also eher optimistisch als zu streng.
Aufruf: python backtest_auto_signal.py [n_bars] (Default 40000 M5-Bars)
"""
from __future__ import annotations
import sys
from datetime import datetime, timezone, timedelta
import MetaTrader5 as mt5
from core.wave_rec import (WaveRecommender, _atr, _ema_last,
_EMA_FAST, _EMA_SLOW, _HTF_DEADBAND)
# ── Exit-Parameter = die LIVE-Werte (core/trailing.py) ────────────────────────
_SL_ATR = 2.0 # Initial-SL (Band 1,82,2 → Ziel 2,0)
_TRAIL = 1.5 # Trailing-Abstand ×ATR (M5-Mult)
_TRAIL_ON = 0.3 # Trail-Start ab diesem Profit ×ATR
_BE_ON = 1.3 # Breakeven-Floor ab diesem Profit ×ATR
_TIMESTOP = 24 # Time-Stop: 120 min = 24 M5-Bars in Phase „Init"
_MAXH = 288 # Sim-Horizont (24 h auf M5)
_ATRMIN = 0.06 # ATR-Floor wie live (_ATR_MIN in trailing.py)
_NIGHT = (0, 1, 2, 3, 4, 5, 6, 7) # Berlin-Stunden = gemessene Kostenfalle
_ADV_BARS = 3 # 15-Min-Regel: 3 M5-Bars = 15 min nach Entry prüfen
_HTF_N = 6 # 6 M5-Bars = 1 M30-Bar
_H1_N = 12 # 12 M30-Bars … (H1 = 12 M5-Bars)
class _NeutralTU:
"""TU ist aus der Empfehlung entfernt — im Test neutral (kein Einfluss)."""
def snapshot(self):
return {"intervals": {}}
def _atr_series(H, L, C, p=14):
tr = [0.0]
for i in range(1, len(C)):
tr.append(max(H[i] - L[i], abs(H[i] - C[i - 1]), abs(L[i] - C[i - 1])))
out = [None] * len(C)
run = 0.0
for i in range(1, len(C)):
run += tr[i]
if i > p:
run -= tr[i - p]
out[i] = run / min(i, p)
return out
def _ema_series(vals, period):
k = 2.0 / (period + 1)
out = [vals[0]]
for v in vals[1:]:
out.append(v * k + out[-1] * (1.0 - k))
return out
def _htf_signs(H, L, C, step):
"""Aggregiert die M5-Bars zu einer höheren TF (step M5-Bars je HTF-Bar) und
liefert je M5-Index das Trend-Vorzeichen (+1/-1/0, Totband _HTF_DEADBAND×ATR)
— LOOK-AHEAD-frei: an Index i zählen nur HTF-Bars, die vor i abgeschlossen sind."""
# HTF-Bars bilden
hc, hh, hl = [], [], []
for s in range(0, len(C) - step + 1, step):
seg_h = H[s:s + step]; seg_l = L[s:s + step]
hh.append(max(seg_h)); hl.append(min(seg_l)); hc.append(C[s + step - 1])
if len(hc) < _EMA_SLOW + 5:
return [0] * len(C)
ef = _ema_series(hc, _EMA_FAST); es = _ema_series(hc, _EMA_SLOW)
hatr = _atr_series(hh, hl, hc)
signs_htf = []
for j in range(len(hc)):
a = hatr[j]
if j < _EMA_SLOW + 2 or not a:
signs_htf.append(0); continue
d = ef[j] - es[j]
signs_htf.append(0 if abs(d) < _HTF_DEADBAND * a else (1 if d > 0 else -1))
# auf M5-Index abbilden: bei i ist der letzte ABGESCHLOSSENE HTF-Bar (i//step)-1
out = [0] * len(C)
for i in range(len(C)):
j = i // step - 1
out[i] = signs_htf[j] if 0 <= j < len(signs_htf) else 0
return out
def _sim(entry, d, atr, H, L, C, j0, adv15=None):
"""Live-Exit-Modell. Rückgabe R = Profit/ATR (brutto, ohne Kosten).
Phasen-Ratsche wie live: Time-Stop greift nur, solange nie Trail-Start erreicht.
`adv15` = 15-Minuten-Regel (User-Wunsch 2026-07-30): steht der Trade nach
_ADV_BARS (3 M5-Bars = 15 min) mehr als adv15×ATR im Minus → schließen.
None = aus. adv15=0.0 bedeutet „schließen wenn überhaupt im Minus"."""
eff = entry - d * _SL_ATR * atr
hw = entry
trail_started = False
end = min(j0 + _MAXH, len(C) - 1)
exit_px = C[end]
for j in range(j0, end + 1):
hi, lo = H[j], L[j]
# SL/Trail-Stop zuerst (konservativ: Stop schlägt Weiterlaufen im selben Bar)
if (lo <= eff) if d > 0 else (hi >= eff):
exit_px = eff; break
hw = max(hw, hi) if d > 0 else min(hw, lo)
prof = (C[j] - entry) * d
# 15-Minuten-Regel: EINMALIGE Prüfung genau beim Bar _ADV_BARS nach Entry.
# Läuft der Trade dann gegen mich → raus (Einstieg war falsch).
if adv15 is not None and (j - j0) == _ADV_BARS - 1:
if prof <= -adv15 * atr:
exit_px = C[j]; break
if prof >= _TRAIL_ON * atr:
trail_started = True
cand = hw - d * _TRAIL * atr
if prof >= _BE_ON * atr:
cand = max(cand, entry) if d > 0 else min(cand, entry)
eff = max(eff, cand) if d > 0 else min(eff, cand)
# Time-Stop: nach 120 min noch in „Init" (nie Trail-Start) → raus
if not trail_started and (j - j0) >= _TIMESTOP:
exit_px = C[j]; break
return (exit_px - entry) * d / atr
def _rep(name, Rs, ref=None):
if not Rs:
print(f" {name:<34} -"); return None
n = len(Rs); w = sum(1 for x in Rs if x > 0); s = sum(Rs)
up = sum(x for x in Rs if x > 0); dn = -sum(x for x in Rs if x < 0)
pf = up / dn if dn > 0 else 9.99
worst = min(Rs)
print(f" {name:<34} n={n:>4} WR={100*w/n:>3.0f}% "
f"ØR={s/n:+.3f} PF={pf:.2f} ΣR={s:+.0f} worst={worst:+.1f}")
return s / n
def scan(bars, H, L, C, SP, A, HTF, H1, EF, ES, w, lo_i, hi_i, min_conf, skip_night, adv15=None):
"""Sequentielle 1-Positions-Simulation: nimmt das ERSTE Signal, wenn flat."""
Rs = []
i = max(lo_i, _EMA_SLOW + 20)
hi_i = min(hi_i, len(C) - _MAXH - 2)
while i < hi_i:
atr = A[i]
if not atr or atr < _ATRMIN:
i += 1; continue
if skip_night:
# Broker-Zeit ist UTC+3, Berlin (Sommer) UTC+2 → Berlin = Broker 1 h
hb = (datetime.fromtimestamp(int(bars[i]["time"]), timezone.utc).hour - 1) % 24
if hb in _NIGHT:
i += 1; continue
ef, es = EF[i], ES[i]
rec, _snap = w._build(ef, es, C[i], atr, "M5", 5,
htf_trend=HTF[i], h1_trend=H1[i])
sig = rec.get("signal")
if sig not in ("LONG", "SHORT") or (rec.get("conf_pct") or 0) < min_conf:
i += 1; continue
d = 1 if sig == "LONG" else -1
entry = C[i]
cost = (SP[i] if SP[i] > 0 else 0.0225) / atr
R = _sim(entry, d, atr, H, L, C, i + 1, adv15) - cost
Rs.append(R)
# nach dem Trade weiterspringen: der Exit fand irgendwo in [i+1, i+_MAXH]
# statt — konservativ mit dem Time-Stop-Fenster weiterrücken (kein Overlap)
i += _TIMESTOP
return Rs
def main():
n = int(sys.argv[1]) if len(sys.argv) > 1 else 40000
if not mt5.initialize():
print(f"MT5-Init fehlgeschlagen: {mt5.last_error()}"); sys.exit(1)
sym = None
for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD"):
if mt5.symbol_info(c):
sym = c; break
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, n + _MAXH + 60)
si = mt5.symbol_info(sym); point = si.point
mt5.shutdown()
if bars is None or len(bars) < 5000:
print("Zu wenige Bars."); sys.exit(1)
H = [float(b["high"]) for b in bars]
L = [float(b["low"]) for b in bars]
C = [float(b["close"]) for b in bars]
SP = [float(b["spread"]) * point for b in bars]
A = _atr_series(H, L, C)
HTF = _htf_signs(H, L, C, _HTF_N) # M30-Trend je M5-Bar
H1 = _htf_signs(H, L, C, _HTF_N * 2) # H1-Trend je M5-Bar (12 M5-Bars)
w = WaveRecommender(_NeutralTU(), mt5.TIMEFRAME_M5)
# EMA-Serien vorab (identisch zur Live-Rekursion, nur ohne Fenster-Neuaufbau)
EF = _ema_series(C, _EMA_FAST)
ES = _ema_series(C, _EMA_SLOW)
N = len(C); mid = N // 2
print("=" * 92)
print(f" AUTO-ENTRY auf die EMPFEHLUNG — {sym} M5 ({N} Bars) · echte _build-Logik")
print(f" Exit=live(SL2,0/Trail1,5/BE1,3/Time-Stop120min) · Kosten=Bar-Spread/ATR")
print(f" Referenz (validierter Auto-Entry Squeeze): ØR +0,14…+0,23 · PF>1")
print("=" * 92)
verdict = {}
# 15-Minuten-Regel (User-Wunsch): welche Schwelle ist die beste? Basis =
# Konf>=55 + Nacht-Sperre, damit nur die Regel selbst variiert.
ADV = [(None, "OHNE 15-Min-Regel (Basis)"), (0.0, "15min raus wenn im Minus"),
(0.15, "15min raus wenn < -0,15xATR"), (0.3, "15min raus wenn < -0,3xATR"),
(0.5, "15min raus wenn < -0,5xATR"), (0.8, "15min raus wenn < -0,8xATR")]
for label, lo, hi in (("H1 (alt)", 0, mid), ("H2 (neu)", mid, N)):
print(f"\n{label} — 15-MIN-REGEL (Basis Konf>=55 + Nacht-Sperre):")
for a, alab in ADV:
Rs = scan(bars, H, L, C, SP, A, HTF, H1, EF, ES, w, lo, hi, 55, True, a)
verdict.setdefault(("adv", a), []).append(_rep(alab, Rs))
print(f"\n{label} — Konfidenz-Achse (ohne 15-Min-Regel):")
for mc in (55, 65, 70, 75):
for night in (False, True):
tag = f"Konf>={mc}" + (" · Nacht-Sperre" if night else "")
Rs = scan(bars, H, L, C, SP, A, HTF, H1, EF, ES, w, lo, hi, mc, night)
oR = _rep(tag, Rs)
verdict.setdefault((mc, night), []).append(oR)
print("\n" + "=" * 92)
print(" URTEIL (trägt nur, wenn ØR in BEIDEN Hälften > 0 UND im Squeeze-Band):")
any_ok = False
for key, vals in sorted(verdict.items(), key=lambda kv: str(kv[0])):
mc, night = key
if len(vals) != 2 or any(v is None for v in vals):
continue
ok = all(v > 0 for v in vals)
band = all(v >= 0.14 for v in vals)
mark = "✅ TRÄGT" if (ok and band) else ("⚠ positiv, unter Squeeze-Band" if ok else "❌ fällt durch")
if ok and band:
any_ok = True
if mc == "adv":
tag = "15Min-Regel " + (str(night) if night is not None else "AUS")
else:
tag = f"Konf>={mc}" + (" +Nacht-Sperre" if night else "")
print(f" {tag:<28} H1 ØR {vals[0]:+.3f} · H2 ØR {vals[1]:+.3f} {mark}")
print("\n " + ("→ mindestens eine Variante trägt: Einbau vertretbar (klein starten, B4-Monitor)."
if any_ok else
"→ KEINE Variante trägt beidhälftig im Squeeze-Band → NICHT einbauen."))
if __name__ == "__main__":
main()