Files
AH-Oil-Trader/backtest_reversal_angle.py
Axel HocksandClaude Opus 5 d052905319 Reversal-Winkelbedingung gemessen: alle drei Varianten fallen durch
Anlass: User-Frage "warum SHORT im starken M5-Aufwaertstrend?". Antwort war ein
WAVE_REV_SHORT (3,25xATR ueberdehnt). Beim Nachrechnen kam heraus, dass die
Winkelbedingung gegen ihre eigene Dokumentation laeuft:

calc_trend_angle liefert 0 Grad = starker AUFWAERTStrend, 180 = ABWAERTS.
ad = angle-90 heisst also: ad<0 = STEIGEND. Der Code triggert
stretch>=+3.0 and ad<=-2 -> SHORT, kommentiert als "Winkel ab" - feuert also bei
ueberkauft UND WEITER STEIGENDEM Winkel, waehrend CLAUDE.md "Einstieg IN
Winkel-Richtung" beschreibt. Dasselbe Vorzeichen im Konfidenz-Block: ein SHORT
bekommt "Winkel bestaetigt" (+5), waehrend der Kurs steigt. Mit synthetischen
Reihen direkt nachgestellt.

Erklaert zwei dokumentierte Befunde: ~50 % Richtungstreffer der Bounce-Anzeige
und "blutet in starken Trends".

Statt das Vorzeichen blind zu drehen (alle Messungen beruhen auf dem
IST-Verhalten): backtest_reversal_angle.py, 80k M5, kanonischer Exit,
Echtkosten, 2 Halbjahre, drei Varianten.

  IST         -0,122 / -0,031  PF 0,79/0,94  n=1100/1615
  KORRIGIERT  -0,173 / -0,061  PF 0,68/0,89  n= 106/ 163
  KONTROLLE   -0,099 / -0,023  PF 0,82/0,96  n=1153/1693

(a) Die Vorzeichenkorrektur ist SCHLECHTER - gut, dass sie nicht blind eingebaut
    wurde. Sie feuert zudem kaum, weil bei 3xATR Ueberdehnung die 14-Bar-
    Regression fast immer noch steigt.
(b) Die Kontrolle OHNE Winkel ist die beste der drei - die Winkelbedingung
    entfernt Trades, die im Schnitt besser waren als die behaltenen.
(c) Der Trigger ist in jeder Variante beidhaelftig negativ.

Gemessen wurde der ROHE Trigger ohne Konfidenz-/Raum-Gate; der Legacy-Recheck
der gegateten Variante kam auf ~Breakeven (PF 0,93/1,06), dieselbe Richtung.
Keine Messung stuetzt die dokumentierten "+0,185 / PF 1,35" mehr.

Noch KEINE Strategieaenderung - das Abschalten ist eine User-Entscheidung.

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

157 lines
6.2 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
"""Reversal-Trigger: IST-Verhalten gegen vorzeichenkorrigierte Variante (2026-08-04).
Anlass: Beim Nachgehen der User-Frage „warum SHORT im starken M5-Aufwärtstrend?"
kam heraus, dass die Reversal-Bedingung in `wave_rec._build` GEGEN ihre eigene
Dokumentation läuft.
`calc_trend_angle` liefert laut Docstring 0° = starker AUFWÄRTStrend
90° = seitwärts
180° = starker ABWÄRTStrend
→ `ad = angle 90`: ad < 0 = STEIGEND, ad > 0 = FALLEND.
Der Code macht:
stretch >= +3.0 und ad <= -2 → SHORT (Kommentar: „Winkel ab")
stretch <= -3.0 und ad >= +2 → LONG (Kommentar: „Winkel auf")
Mit obiger Konvention heißt das: SHORT bei überkauft UND WEITER STEIGENDEM
Winkel; LONG bei überverkauft UND WEITER FALLENDEM. CLAUDE.md beschreibt aber
„Einstieg IN Winkel-Richtung" — also genau umgekehrt.
⚠ Warum das nicht einfach umgedreht wurde: sämtliche Messungen (`backtest_bounce.py`,
der Legacy-Recheck, `backtest_bounce_reliability.py`) rufen dasselbe `_build` — sie
beschreiben also das IST-Verhalten. Ein Vorzeichenwechsel machte aus einem gemessenen
Setup ein ungemessenes. Deshalb hier beide Varianten gegeneinander, plus eine
Kontrolle ohne Winkelbedingung (trägt der Winkel überhaupt etwas bei?).
Aufbau: M5 (Basis-TF), kanonischer Live-Exit (`core/exit_model.LIVE`), Echtkosten
(Bar-Spread/ATR), sequentielle 1-Positions-Sim, 2 Halbjahre.
Nur der TRIGGER wird verglichen — ohne Konfidenz-Gate/Raum-Gate, sonst mischen sich
Effekte. Maßstab: beidhälftig ØR>0 und PF>1.
Aufruf: python backtest_reversal_angle.py [bars]
"""
import sys
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.exit_model import LIVE, simulate
from core.wave_rec import _ANGLE_DEAD, _ANGLE_LR, _EMA_SLOW, _REVERSAL_STRETCH
_ATRMIN = 0.06
_COOL = 3
def _ema(vals, p):
k = 2.0 / (p + 1)
out = [vals[0]]
for v in vals[1:]:
out.append(out[-1] + k * (v - out[-1]))
return out
def _atr_series(H, L, C, p=14):
t = [0.0]
for i in range(1, len(C)):
t.append(max(H[i] - L[i], abs(H[i] - C[i - 1]), abs(L[i] - C[i - 1])))
return [(sum(t[max(1, i - p + 1):i + 1]) / max(1, len(t[max(1, i - p + 1):i + 1])))
if i else None for i in range(len(C))]
def st(Rs):
if not Rs:
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)
return dict(n=n, wr=100 * w / n, oR=s / n, pf=(up / dn if dn > 0 else 9.99), sum=s)
def line(lbl, s):
if not s:
return f" {lbl:<22} — (keine Trades)"
return (f" {lbl:<22} n={s['n']:>5} WR={s['wr']:>3.0f}% ØR={s['oR']:+.3f} "
f"PF={s['pf']:>4.2f} ΣR={s['sum']:>+7.0f}")
def run(H, L, C, A, SP, E50, AD, lo, hi, variant):
def cost(i, atr):
return (SP[i] if SP[i] > 0 else 0.0225) / atr
Rs = []
i = max(lo, _EMA_SLOW + _ANGLE_LR + 5)
while i < hi:
atr = A[i]
if not atr or atr < _ATRMIN:
i += 1; continue
stretch = (C[i] - E50[i]) / atr
ad = AD[i]
d = 0
if variant == "IST": # wie der Live-Code heute
if stretch <= -_REVERSAL_STRETCH and ad >= _ANGLE_DEAD:
d = 1
elif stretch >= _REVERSAL_STRETCH and ad <= -_ANGLE_DEAD:
d = -1
elif variant == "KORR": # vorzeichenkorrigiert = dokumentierte Absicht
if stretch <= -_REVERSAL_STRETCH and ad <= -_ANGLE_DEAD:
d = 1 # überverkauft + Winkel STEIGT = gedreht
elif stretch >= _REVERSAL_STRETCH and ad >= _ANGLE_DEAD:
d = -1 # überkauft + Winkel FÄLLT = gedreht
else: # KONTROLLE: nur Überdehnung, kein Winkel
if stretch <= -_REVERSAL_STRETCH:
d = 1
elif stretch >= _REVERSAL_STRETCH:
d = -1
if d == 0:
i += 1; continue
a = max(atr, _ATRMIN)
r, xj = simulate(C[i], d, a, H, L, C, i + 1, LIVE,
timestop=True, use_tp=True, ret_bar=True)
Rs.append(r - cost(i, a))
i = xj + _COOL
return Rs
def main():
n = int(sys.argv[1]) if len(sys.argv) > 1 else 80000
mt5.initialize()
sym = next((c for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD")
if mt5.symbol_info(c)), None)
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, n)
point = mt5.symbol_info(sym).point
mt5.shutdown()
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)
E50 = _ema(C, _EMA_SLOW)
# Winkel EINMAL je Bar (variantenunabhängig), exakt wie im Live-Code aufgerufen
AD = [0.0] * len(C)
for i in range(_ANGLE_LR + 2, len(C)):
AD[i] = calc_trend_angle(C[i - (_ANGLE_LR + 1):i + 1], _ANGLE_LR) - 90.0
N = len(C); mid = N // 2
print("=" * 88)
print(f" Reversal-Trigger: IST vs. vorzeichenkorrigiert — {sym} M5 ({N} Bars)")
print(f" Kanonischer Exit (SL {LIVE.sl_atr}× · Trail {LIVE.mult}× · BE {LIVE.be}×"
f" · Time-Stop), Echtkosten, 2 Halbjahre")
print(f" Trigger: |stretch| ≥ {_REVERSAL_STRETCH}×ATR, Winkel-Totband ±{_ANGLE_DEAD}°")
print("=" * 88)
beschreibung = {
"IST": "IST (Live heute): überkauft + Winkel STEIGT → SHORT",
"KORR": "KORRIGIERT (Doku): überkauft + Winkel FÄLLT → SHORT",
"KTRL": "KONTROLLE: nur Überdehnung, Winkel ignoriert",
}
for v in ("IST", "KORR", "KTRL"):
s1 = st(run(H, L, C, A, SP, E50, AD, 0, mid, v))
s2 = st(run(H, L, C, A, SP, E50, AD, mid, N - LIVE.max_hold - 1, v))
print(f"\n {beschreibung[v]}")
print(line("H1 (alt)", s1))
print(line("H2 (neu)", s2))
ok = (s1 and s2 and s1["oR"] > 0 and s2["oR"] > 0
and s1["pf"] > 1 and s2["pf"] > 1)
print(f" → {'ROBUST (beide Hälften positiv)' if ok else 'fällt durch'}")
print()
if __name__ == "__main__":
main()