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
+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"PowerShell(Select-String -Path \"C:\\\\Users\\\\ah\\\\Downloads\\\\tradingrobot\\\\brent_widget.log.5\" -Pattern \"BUY |SELL |extern geschlossen|Auto-Deak|AKTIVIERT\" | ForEach-Object { $_.Line } | Select-Object -First 80)",
"PowerShell(Rename-Item \"c:\\\\Users\\\\ah\\\\Downloads\\\\tradingrobot\\\\core\\\\analysis.py\" \"analysis.py.bak\")"
]
}
}
+44
View File
@@ -0,0 +1,44 @@
# ── SECRETS (NIEMALS committen) ─────────────────────────────────────────────
oil_widget_config.ini
API Key*.txt
*.key
.env
# ── Datenbank + Backups ─────────────────────────────────────────────────────
*.db
*.db-shm
*.db-wal
*.db.bak-*
oil_widget_history.db*
test_*.db
# ── Logs ────────────────────────────────────────────────────────────────────
*.log
*.log.*
# ── Laufzeit-/State-Dateien (werden vom Server erzeugt) ──────────────────────
emergency_state.json
runtime_state.json
report_state.json
oil_widget_translations.json
# ── Persönliche/lokale Umgebung ─────────────────────────────────────────────
userpath_backup.txt
.claude/settings.local.json
.removed_backup/
# ── Python ──────────────────────────────────────────────────────────────────
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.pytest_cache/
.mypy_cache/
# ── OS/Editor ───────────────────────────────────────────────────────────────
Thumbs.db
desktop.ini
.DS_Store
.vscode/
.idea/
+1179
View File
File diff suppressed because it is too large Load Diff
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Ausführungs-Analyse (Track B, Mess-Kalibrierung — KEINE Strategie-Änderung):
A) Kosten-Profil: echter Spread je Berlin-Stunde (Bar-Feld `spread`, in Points)
absolut und als ×ATR — ersetzt die pauschale 0,1×ATR-Kostenannahme.
B) Slippage: SL-Closes, die SCHLECHTER als der Initial-SL ausgeführt wurden
(sichere Untergrenze der echten Slippage; getrailte SLs sind nicht rekonstruierbar).
C) MAE/MFE der Live-Trades: wie weit liefen echte Trades ins Minus (MAE) und
ins Plus (MFE), normiert auf ATR≈|EntryInitialSL|/2 — validiert SL-Band,
Breakeven-Schwelle (1,3) und Trailing an LIVE-Daten statt Simulation.
Aufruf: python analyze_execution.py [m5_bars] [m1_bars]
"""
import sys, sqlite3, datetime as dt
from zoneinfo import ZoneInfo
from collections import defaultdict
import MetaTrader5 as mt5
_BROKER_OFF = 3 * 3600
_BERLIN = ZoneInfo("Europe/Berlin")
DB = "oil_widget_history.db"
def bhour(broker_ts):
return dt.datetime.fromtimestamp(int(broker_ts) - _BROKER_OFF,
tz=dt.timezone.utc).astimezone(_BERLIN).hour
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])))
out = []
for i in range(len(C)):
w = t[max(1, i-p+1):i+1]
out.append(sum(w)/len(w) if w else None)
return out
def main():
n5 = int(sys.argv[1]) if len(sys.argv) > 1 else 80000
n1 = int(sys.argv[2]) if len(sys.argv) > 2 else 100000
mt5.initialize()
sym = None
for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD"):
if mt5.symbol_info(c):
sym = c; break
si = mt5.symbol_info(sym)
point = si.point
bars5 = None
for req in (n5, 80000, 60000, 40000):
bars5 = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, req)
if bars5 is not None and len(bars5) > 2000: break
bars1 = None
for req in (n1, 60000, 30000, 15000):
bars1 = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M1, 0, req)
if bars1 is not None and len(bars1) > 1000: break
mt5.shutdown()
# ── A) Kosten-Profil: Spread je Berlin-Stunde ────────────────────────────
print("=" * 84)
print(f" A) KOSTEN-PROFIL — {sym}, {len(bars5)} M5-Bars, Spread aus Bar-Feld (Points×{point})")
print("=" * 84)
H = [float(b["high"]) for b in bars5]; L = [float(b["low"]) for b in bars5]
C = [float(b["close"]) for b in bars5]
AT = _atr_series(H, L, C)
byh = defaultdict(list)
for i, b in enumerate(bars5):
atr = AT[i]
if not atr or atr <= 0: continue
sp = float(b["spread"]) * point
if sp <= 0: continue
byh[bhour(b["time"])].append((sp, sp / atr))
print(f" {'Std':>3} {'Ø-Spread':>9} {'×ATR':>6} (Kostenannahme bisher pauschal 0,10×ATR)")
tot = []
for h in range(24):
v = byh.get(h)
if not v: continue
sp = sum(x[0] for x in v)/len(v); rel = sum(x[1] for x in v)/len(v)
tot += v
mark = " ⚠ teuer" if rel > 0.15 else (" günstig" if rel < 0.07 else "")
print(f" {h:>3} {sp:>9.4f} {rel:>6.3f}{mark}")
if tot:
print(f" ALLE Ø-Spread {sum(x[0] for x in tot)/len(tot):.4f} · {sum(x[1] for x in tot)/len(tot):.3f}×ATR")
# ── Trades laden (B + C) ─────────────────────────────────────────────────
con = sqlite3.connect(DB); con.row_factory = sqlite3.Row
rows = [dict(r) for r in con.execute(
"SELECT * FROM trades WHERE exit_time IS NOT NULL AND entry_price IS NOT NULL "
"ORDER BY entry_time")]
# ── B) Slippage bei SL-Closes ────────────────────────────────────────────
print("\n" + "=" * 84)
print(" B) SLIPPAGE — SL-Closes schlechter als der Initial-SL (sichere Untergrenze)")
print("=" * 84)
slips = []
n_sl = 0
for r in rows:
if r["closed_by"] != "sl" or not r["sl_at_entry"]: continue
n_sl += 1
d = 1 if r["direction"] in ("BUY", "LONG", "buy") else -1
gap = (r["sl_at_entry"] - r["exit_price"]) * d # >0 = schlechter als Initial-SL
if gap > 0:
slips.append((gap, r))
print(f" SL-Closes gesamt: {n_sl} · davon SCHLECHTER als Initial-SL: {len(slips)}")
if slips:
gaps = [g for g, _ in slips]
gaps.sort()
print(f" Slippage: Ø {sum(gaps)/len(gaps):.3f} · Median {gaps[len(gaps)//2]:.3f} "
f"· Max {max(gaps):.3f} (Preis-Punkte)")
worst = sorted(slips, key=lambda x: -x[0])[:5]
for g, r in worst:
t = dt.datetime.fromtimestamp(r["entry_time"]).strftime("%d.%m %H:%M")
print(f" {t} {r['direction']:<4} SL {r['sl_at_entry']:.3f} → Exit "
f"{r['exit_price']:.3f} Slippage {g:.3f} (netto {((r['pnl'] or 0)+(r['commission'] or 0)):+.2f})")
# ── C) MAE/MFE der Live-Trades (M1-Fenster) ─────────────────────────────
print("\n" + "=" * 84)
print(f" C) MAE/MFE live — M1-Abdeckung {len(bars1)} Bars "
f"(~{len(bars1)/60/24*1.0:.0f} Handelstage), ATR≈|EntryInitSL|/2")
print("=" * 84)
T1 = [int(b["time"]) for b in bars1]
H1 = [float(b["high"]) for b in bars1]; L1 = [float(b["low"]) for b in bars1]
t0 = T1[0]
import bisect
cov = 0
mae_w, mae_l, mfe_w, mfe_l = [], [], [], []
for r in rows:
eb = int(r["entry_time"]) + _BROKER_OFF # lokale Epoch → Broker-Epoch
xb = int(r["exit_time"]) + _BROKER_OFF
if eb < t0 or not r["sl_at_entry"]: continue
i0 = bisect.bisect_left(T1, eb); i1 = bisect.bisect_right(T1, xb)
if i1 - i0 < 1: continue
d = 1 if r["direction"] in ("BUY", "LONG", "buy") else -1
entry = float(r["entry_price"])
atr_est = abs(entry - float(r["sl_at_entry"])) / 2.0
if atr_est <= 0: continue
seg_h = H1[i0:i1]; seg_l = L1[i0:i1]
# adverse/favorable Exkursion je Richtung
if d > 0:
mae = max(0.0, entry - min(seg_l)); mfe = max(0.0, max(seg_h) - entry)
else:
mae = max(0.0, max(seg_h) - entry); mfe = max(0.0, entry - min(seg_l))
net = (r["pnl"] or 0) + (r["commission"] or 0)
cov += 1
(mae_w if net > 0 else mae_l).append(mae / atr_est)
(mfe_w if net > 0 else mfe_l).append(mfe / atr_est)
def st(v):
if not v: return "n=0"
v = sorted(v)
return (f"n={len(v):>3} Ø={sum(v)/len(v):.2f} Median={v[len(v)//2]:.2f} "
f"P90={v[int(len(v)*0.9)]:.2f}")
print(f" Abgedeckte Trades: {cov}")
print(f" MAE Gewinner {st(mae_w)} (wie tief liefen GEWINNER ins Minus)")
print(f" MAE Verlierer {st(mae_l)}")
print(f" MFE Gewinner {st(mfe_w)}")
print(f" MFE Verlierer {st(mfe_l)} (wie viel Plus gaben VERLIERER wieder her)")
if mfe_l:
gave = sum(1 for x in mfe_l if x >= 1.3) / len(mfe_l)
print(f" Verlierer, die ≥1,3×ATR im Plus waren (Breakeven hätte greifen müssen): {100*gave:.0f}%")
if mae_w:
deep = sum(1 for x in mae_w if x >= 1.8) / len(mae_w)
print(f" Gewinner, die ≥1,8×ATR im Minus waren (SL-Band-Nähe überlebt): {100*deep:.0f}%")
if __name__ == "__main__":
main()
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Flatter-Analyse der Durchbruchs-Empfehlung (letzte 2 Tage, M1-Auflösung):
Rekonstruiert P(break) minütlich WIE DIE ENGINE (M5-Serie inkl. LAUFENDER Kerze,
deren Close = aktueller Kurs — genau das lässt mom3/mom6 im Bar springen) und misst
in Level-Nähe (≤0,6×ATR):
- P-Spanne innerhalb einzelner M5-Kerzen (wie stark springt P intra-Bar)
- Schwellen-Kreuzungen (close↔laufen bei 60 %) je Annäherungs-Episode
- Zerlegung: Sprünge durch mom3 (laufende Kerze) vs. Level-Wechsel
"""
import sys, math
import MetaTrader5 as mt5
from core.engine import _p_break
from core.wave_rec import _EMA_FAST, _EMA_SLOW
_PIV_K=3; _LOOKBACK=300; _THR=0.60
def ema_last(vals,p):
k=2.0/(p+1); e=vals[0]
for x in vals[1:]: e=x*k+e*(1-k)
return e
def atr14(H,L,C):
tr=[max(H[i]-L[i],abs(H[i]-C[i-1]),abs(L[i]-C[i-1])) for i in range(1,len(C))]
return sum(tr[-14:])/14
def main():
days=int(sys.argv[1]) if len(sys.argv)>1 else 2
mt5.initialize(); sym='SpotCrude'
m1=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M1,0,days*1440+50)
m5=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,days*288+_LOOKBACK+60)
mt5.shutdown()
T5=[int(b["time"]) for b in m5]
O5=[float(b["open"]) for b in m5]; H5=[float(b["high"]) for b in m5]
L5=[float(b["low"]) for b in m5]; C5=[float(b["close"]) for b in m5]
T1=[int(b["time"]) for b in m1]; C1=[float(b["close"]) for b in m1]
import bisect
ep=[] # aktive Annäherungs-Episode: Liste von (P, level, mom3)
episodes=[] # abgeschlossene Episoden
per_bar={} # (m5-index) -> [P...] für Intra-Bar-Spanne
n_eval=0
for i1 in range(30, len(m1)):
t=T1[i1]; px=C1[i1]
j=bisect.bisect_right(T5,t)-1 # laufende/letzte M5-Kerze
if j < _LOOKBACK+10: continue
# M5-Serie wie die Engine sie sieht: abgeschlossene Bars + laufende Kerze
# mit Close = aktuellem M1-Kurs
closes=C5[j-_LOOKBACK:j]+[px]
highs =H5[j-_LOOKBACK:j]+[max(px,O5[j])]
lows =L5[j-_LOOKBACK:j]+[min(px,O5[j])]
atr=max(atr14(highs,lows,closes),0.12)
mom6=(closes[-1]-closes[-7])/atr
mom3=(closes[-1]-closes[-4])/atr
ediff=(ema_last(closes,_EMA_FAST)-ema_last(closes,_EMA_SLOW))/atr
# Pivot-Level aus abgeschlossenen Bars
ph=[H5[k] for k in range(j-_LOOKBACK+_PIV_K, j-_PIV_K)
if H5[k]==max(H5[k-_PIV_K:k+_PIV_K+1])]
# LONG-Sicht: nächster Widerstand über Kurs (analog gilt Short symmetrisch)
cands=[p for p in ph if p>px]
level=min(cands) if cands else None
if level is None or (level-px) > 0.6*atr:
if ep: episodes.append(ep); ep=[]
continue
wt=1.0 if ediff>0 else 0.0
dist=abs(level-px)/atr
P=_p_break(mom6,mom3,wt,dist)
ep.append((P,level,mom3)); n_eval+=1
per_bar.setdefault(j,[]).append(P)
if ep: episodes.append(ep)
print("="*84)
print(f" P(break)-Flattern — letzte {days} Tage, {n_eval} Minuten in Level-Nähe (≤0,6×ATR)")
print("="*84)
# Intra-Bar-Spanne
spans=[max(v)-min(v) for v in per_bar.values() if len(v)>=3]
if spans:
spans.sort()
print(f"\nP-Spanne INNERHALB einer M5-Kerze (n={len(spans)} Kerzen):")
print(f" Median {100*spans[len(spans)//2]:.0f} Pp · P90 {100*spans[int(len(spans)*0.9)]:.0f} Pp · Max {100*max(spans):.0f} Pp")
# Episoden: Schwellen-Flips
flips=[]; levsw=[]
for e in episodes:
if len(e)<3: continue
f=sum(1 for k in range(1,len(e)) if (e[k][0]<_THR)!=(e[k-1][0]<_THR))
sw=sum(1 for k in range(1,len(e)) if abs(e[k][1]-e[k-1][1])>1e-9)
flips.append(f); levsw.append(sw)
if flips:
flips.sort()
print(f"\nAnnäherungs-Episoden: {len(flips)} · Ø-Dauer "
f"{sum(len(e) for e in episodes if len(e)>=3)/len(flips):.0f} min")
print(f" close↔laufen-Flips (60%-Schwelle) je Episode: "
f"Median {flips[len(flips)//2]} · P90 {flips[int(len(flips)*0.9)]} · Max {max(flips)}")
print(f" Episoden mit ≥2 Flips: {100*sum(1 for f in flips if f>=2)/len(flips):.0f}% "
f"· Level-Wechsel je Episode Ø {sum(levsw)/len(levsw):.1f}")
# mom3-Beitrag: P-Änderung je Minute vs mom3-Änderung
dP=[]; dM=[]
for e in episodes:
for k in range(1,len(e)):
if abs(e[k][1]-e[k-1][1])<1e-9: # gleiches Level → reiner Feature-Effekt
dP.append(abs(e[k][0]-e[k-1][0])); dM.append(abs(e[k][2]-e[k-1][2]))
if dP:
print(f"\nMinuten-Sprünge bei GLEICHEM Level (n={len(dP)}):")
print(f" Ø |ΔP|/min = {100*sum(dP)/len(dP):.1f} Pp · Ø |Δmom3|/min = {sum(dM)/len(dM):.2f}×ATR")
big=[p for p,m in zip(dP,dM) if m>0.3]
print(f" bei |Δmom3|>0,3: Ø |ΔP| = {100*sum(big)/max(1,len(big)):.1f} Pp "
f"({100*len(big)/len(dP):.0f}% der Minuten) → mom3 (laufende Kerze) ist der Treiber")
if __name__=="__main__":
main()
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""S/R-Wellen-Analyse (Track B):
TEIL 1 Containment: Welcher %-Anteil der Wellen (EMA-Richtungsmoves) bleibt
INNERHALB der S/R-Linien — d. h. erreicht das gegenüberliegende Level
und PRALLT AB, statt durchzubrechen? (Basisrate P(break) am Touch.)
TEIL 2 P(Durchbruch)-Modell: logistische Regression auf beobachtbaren Merkmalen
am Touch (Anlauf-Momentum 6/3 Bars, EMA-mit-Trend, Level-Distanz), auf H1
trainiert und auf H2 KALIBRIERT geprüft (predicted vs. echte Bruchrate je
Bin) — ein P ist nur brauchbar, wenn es out-of-sample kalibriert ist.
Break = nach Touch +0,5×ATR JENSEITS des Levels binnen 12 Bars, bevor 0,5×ATR zurück.
"""
import sys
import numpy as np
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (_EMA_FAST, _EMA_SLOW, _N_BARS, _ANGLE_LR, _ANGLE_DEAD,
_REVERSAL_STRETCH, _STRETCH_MAX)
_MAXH=200; _ATRMIN=0.12; _PIV_K=3; _LOOKBACK=300; _BRK_W=12; _BRK_ATR=0.5
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 collect(a,b,H,L,C,EF,ES,AT):
"""Sammelt Wellen-Touches → Liste (break?, feat-vector, reached?)."""
TH=_REVERSAL_STRETCH; out=[]; n_wave=0; n_reach=0
for i in range(max(a,_N_BARS,_LOOKBACK), min(b,len(C)-_MAXH-1)):
atr=AT[i]
if not atr or atr<=0: continue
atr=max(atr,_ATRMIN); es=ES[i]; ef=EF[i]
stretch=(C[i]-es)/atr
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR); ad=ang-90.0
d=0
if stretch<=-TH and ad>=_ANGLE_DEAD: d=1
elif stretch>=TH and ad<=-_ANGLE_DEAD: d=-1
elif abs(stretch)<_STRETCH_MAX: d=1 if ef>es else -1 if ef<es else 0
if not d: continue
n_wave+=1
entry=C[i]
# nächstes gegenüberliegendes Pivot-Level
phis,plos=[],[]
for j in range(i-_LOOKBACK+_PIV_K, i-_PIV_K):
if H[j]==max(H[j-_PIV_K:j+_PIV_K+1]): phis.append(H[j])
if L[j]==min(L[j-_PIV_K:j+_PIV_K+1]): plos.append(L[j])
if d>0:
cands=[p for p in phis if p>entry+0.3*atr]; level=min(cands) if cands else None
else:
cands=[p for p in plos if p<entry-0.3*atr]; level=max(cands) if cands else None
if level is None: continue
# Touch suchen (bis MAXH), solange die Welle "lebt" (kein 2×ATR-Gegenlauf)
jt=None
for j in range(i+1, min(i+_MAXH, len(C)-_BRK_W)):
if ((H[j]>=level) if d>0 else (L[j]<=level)): jt=j; break
if ((entry-L[j]) if d>0 else (H[j]-entry))*1 >= 2.0*atr: break # Welle tot
if jt is None: continue
n_reach+=1
up=level+d*_BRK_ATR*atr; dn=level-d*_BRK_ATR*atr; brk=False
for j in range(jt, min(jt+_BRK_W, len(H))):
if (H[j]>=up) if d>0 else (L[j]<=up): brk=True; break
if (L[j]<=dn) if d>0 else (H[j]>=dn): brk=False; break
mom6=(C[jt]-C[max(0,jt-6)])*d/atr
mom3=(C[jt]-C[max(0,jt-3)])*d/atr
wt=1.0 if (EF[jt]-ES[jt])*d>0 else 0.0
distlvl=abs(level-entry)/atr
out.append((1.0 if brk else 0.0, [mom6,mom3,wt,distlvl]))
return out, n_wave, n_reach
def fit_logreg(X,y,iters=3000,lr=0.3):
n,m=X.shape; Xb=np.hstack([np.ones((n,1)),X]); w=np.zeros(m+1)
for _ in range(iters):
p=1/(1+np.exp(-(Xb@w))); w-=lr*(Xb.T@(p-y))/n
return w
def predict(w,X):
return 1/(1+np.exp(-(np.hstack([np.ones((X.shape[0],1)),X])@w)))
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 80000
mt5.initialize(); sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
bars=None
for req in (n,80000,60000,40000):
bars=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,req)
if bars is not None and len(bars)>2000: break
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]
EF=_ema_series(C,_EMA_FAST); ES=_ema_series(C,_EMA_SLOW); AT=_atr_series(H,L,C)
mid=len(C)//2
ev1,w1,r1=collect(_N_BARS,mid,H,L,C,EF,ES,AT)
ev2,w2,r2=collect(mid,len(C),H,L,C,EF,ES,AT)
print("="*90)
print(f" S/R-WELLEN — {sym} M5 ({len(C)} Bars) Level=Pivot(k{_PIV_K}), Break=+{_BRK_ATR}×ATR in {_BRK_W} Bars")
print("="*90)
print("\nTEIL 1 — Containment (bleibt die Welle innerhalb der S/R-Linien?):")
for lbl,ev,nw,nr in (("H1 (alt)",ev1,w1,r1),("H2 (neu)",ev2,w2,r2)):
if not ev: continue
pbreak=100*sum(e[0] for e in ev)/len(ev)
print(f" {lbl}: {nw} Wellen · {100*nr/max(1,nw):.0f}% erreichen das Level · "
f"davon {pbreak:.0f}% DURCHBRUCH → {100-pbreak:.0f}% bleiben drin (Abpraller)")
print("\nTEIL 2 — P(Durchbruch)-Modell (Merkmale: mom6, mom3, mit-Trend, Level-Dist):")
X1=np.array([e[1] for e in ev1]); y1=np.array([e[0] for e in ev1])
X2=np.array([e[1] for e in ev2]); y2=np.array([e[0] for e in ev2])
mu=X1.mean(0); sd=X1.std(0)+1e-9
w=fit_logreg((X1-mu)/sd,y1)
p2=predict(w,(X2-mu)/sd)
# AUC (schnell, via Rangvergleich)
order=np.argsort(p2); ranks=np.empty_like(order,dtype=float); ranks[order]=np.arange(1,len(p2)+1)
npos=y2.sum(); nneg=len(y2)-npos
auc=(ranks[y2==1].sum()-npos*(npos+1)/2)/(npos*nneg) if npos and nneg else float('nan')
print(f" Trainiert auf H1, geprüft auf H2 · AUC={auc:.3f} (0,5=Zufall; >0,6=brauchbar trennscharf)")
print(f" Koeffizienten (standardisiert): mom6={w[1]:+.2f} mom3={w[2]:+.2f} "
f"mitTrend={w[3]:+.2f} dist={w[4]:+.2f} · Basis(intercept)={w[0]:+.2f}")
print("\n Kalibrierung auf H2 (vorhergesagtes P vs. echte Bruchrate):")
print(f" {'P-Bin':<12}{'n':>7}{'Ø-Vorhersage':>14}{'echt-Bruch':>12}")
edges=[0,0.25,0.30,0.35,0.40,0.45,0.55,1.01]
for k in range(len(edges)-1):
m=(p2>=edges[k])&(p2<edges[k+1])
if m.sum()<30: continue
print(f" {edges[k]:.2f}{edges[k+1]:.2f} {int(m.sum()):>7}{p2[m].mean():>13.0%}{y2[m].mean():>12.0%}")
# Nutz-Regel: Trenn-Schwelle
for thr in (0.40,0.45,0.50):
run=p2>=thr
print(f"\n Regel „laufen lassen wenn P(break)≥{thr:.0%}\": {100*run.mean():.0f}% der Touches laufen, "
f"davon {100*y2[run].mean():.0f}% brechen wirklich durch · "
f"Rest schließen: {100*y2[~run].mean():.0f}% brechen (Fehl-Close)")
if __name__=="__main__":
main()
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Rekonstruiert die heutigen Empfehlungen (echte _build-Logik) über die M5-Bars
und korreliert sie mit dem Kurs + den heutigen Trades. Zeigt Signal-Wechsel,
Trefferquote und Auffälligkeiten."""
import datetime as dt
import sqlite3
import MetaTrader5 as mt5
from core.wave_rec import (WaveRecommender, _atr, _ema_last, _EMA_FAST, _EMA_SLOW,
_N_BARS, _HTF_DEADBAND, _ANGLE_LR)
from core.analysis import calc_trend_angle
OFF = 3600 # Broker(UTC+3)→Berlin(UTC+2, Juni): epoch-1h
class _TU:
def snapshot(self): return {"intervals": {}}
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 bt(ts): return dt.datetime.utcfromtimestamp(ts-OFF) # Berlin
mt5.initialize()
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, 700)
m30=mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M30, 0, 200)
mt5.shutdown()
T=[int(b["time"]) for b in bars]; H=[float(b["high"]) for b in bars]
L=[float(b["low"]) for b in bars]; C=[float(b["close"]) for b in bars]
mT=[int(b["time"]) for b in m30]; mc=[float(b["close"]) for b in m30]
mh=[float(b["high"]) for b in m30]; ml=[float(b["low"]) for b in m30]
mEf=_ema_series(mc,_EMA_FAST); mEs=_ema_series(mc,_EMA_SLOW); mA=_atr_series(mh,ml,mc)
def m30s(ts):
lo,hi,idx=0,len(mT)-1,-1
while lo<=hi:
md=(lo+hi)//2
if mT[md]<=ts: idx=md; lo=md+1
else: hi=md-1
if idx<_EMA_SLOW or mA[idx] is None or mA[idx]<=0: return 0
d=mEf[idx]-mEs[idx]
return 0 if abs(d)<_HTF_DEADBAND*mA[idx] else (1 if d>0 else -1)
today = bt(T[-1]).date()
w=WaveRecommender(_TU(), mt5.TIMEFRAME_M5)
rows=[] # (i, berlin, close, signal, conf, reason)
K=6
for i in range(_N_BARS, len(C)):
b=bt(T[i])
if b.date()!=today: continue
wc=C[i-_N_BARS:i]; wh=H[i-_N_BARS:i]; wl=L[i-_N_BARS:i]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: continue
ef=_ema_last(wc,_EMA_FAST); es=_ema_last(wc,_EMA_SLOW)
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR)
rec,_=w._build(ef,es,C[i-1],atr,"M5",5,htf_trend=m30s(T[i]),
angle=ang,hour=b.hour)
fwd=(C[i+K]-C[i]) if i+K<len(C) else None
rows.append((i,b,C[i],rec["signal"],rec["conf_pct"],(rec["reasons"] or [""])[0],fwd))
# Signal-Wechsel
print("="*72); print(f" Heutige Empfehlung vs Kurs — {sym} {today} (M5, Live-Logik)"); print("="*72)
print("Signal-WECHSEL (Zeit Berlin · Kurs · Signal · conf · Grund):")
prev=None
for (i,b,px,sig,cf,rs,fwd) in rows:
if sig!=prev:
print(f" {b.strftime('%H:%M')} {px:7.3f} {sig:6} {cf:>3} {rs[:54]}")
prev=sig
# Trefferquote der Nicht-WARTEN-Signale (Vorlauf 6 Bars)
ev=[(sig,fwd) for (_,_,_,sig,_,_,fwd) in rows if sig in ("LONG","SHORT") and fwd is not None]
if ev:
win=sum(1 for s,f in ev if (f>0)==(s=="LONG"))
edge=sum((f if s=="LONG" else -f) for s,f in ev)/len(ev)
nL=sum(1 for s,_ in ev if s=="LONG"); nS=len(ev)-nL
print(f"\nSignale heute: {len(ev)} (LONG {nL} / SHORT {nS}) · Treffer {100*win/len(ev):.0f}% · Oe-Edge {edge:+.4f}")
warten=sum(1 for (_,_,_,s,_,_,_) in rows if s=="WARTEN")
print(f"WARTEN-Bars: {warten}/{len(rows)} ({100*warten/max(1,len(rows)):.0f}%)")
# Trades heute
print("\nTrades heute (DB):")
c=sqlite3.connect('oil_widget_history.db'); c.row_factory=sqlite3.Row
t0=dt.datetime.now().replace(hour=0,minute=0,second=0,microsecond=0).timestamp()
for r in c.execute('SELECT entry_time,exit_time,direction,entry_price,exit_price,pnl,closed_by FROM trades WHERE exit_time>=? ORDER BY exit_time',(t0,)):
print(f" {dt.datetime.fromtimestamp(r['entry_time']).strftime('%H:%M')}{dt.datetime.fromtimestamp(r['exit_time']).strftime('%H:%M')} "
f"{r['direction']:4} {r['entry_price']:.3f}{r['exit_price']:.3f} pnl {r['pnl']:+.2f} {r['closed_by']}")
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Früh-Ausstieg NUR bei Trades OHNE Signal-Deckung (2026-07-23, User-Idee nach dem
57,34-€-Trade: manueller LONG gegen WARTEN, kein Squeeze/Signal-Flip beteiligt).
Der pauschale Früh-Ausstieg (`_ADVERSE_EXIT_ATR`) ist AUS — er kappte gemessen auch
gute Trend-Trades bei normalen Rücksetzern. Hypothese: bei Trades OHNE Signal-Deckung
(Totband/WARTEN-Zone — die B0-Checkliste zeigt live 39% WR/166€ für diese Klasse)
könnte ein früher Adverse-Exit netto helfen, weil diese Population ohnehin schwächer
ist und weniger "gute" Trades träfe.
Zwei Populationen (EMA12/50, Totband wie live):
SIG = frischer EMA-Cross MIT Signal (|diff|≥Totband) — die normale, validierte Klasse.
NOSIG = Entry INNERHALB der Totband-Zone (WARTEN), Richtung = Vorzeichen des (kleinen)
EMA-Diffs — Proxy für einen manuellen Trade "in Richtung der Drift, ohne
bestätigtes Signal" (genau der Fall des 57-€-Trades).
Je Population: BASE-Exit (Live: SL 2,0×ATR+Trailing 1,5+BE 1,3) vs. BASE+Adverse-Exit
(schließt sofort, wenn der Kurs X×ATR gegen den Einstieg läuft, bevor SL/Trail greift).
2 Halbjahre, Echtkosten. Verdict: Adverse-Exit nur für NOSIG einbauen, wenn er dort in
BEIDEN Hälften verbessert — SIG bleibt unangetastet (bereits gemessen: schadet dort).
"""
import sys
import MetaTrader5 as mt5
from core.wave_rec import _EMA_FAST, _EMA_SLOW, _TREND_DEADBAND
_MAXH = 288; _ATRMIN = 0.06; _COOL = 12
def _ema(C, p):
k = 2.0 / (p + 1); e = C[0]; out = [e]
for x in C[1:]:
e += k * (x - e); out.append(e)
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 sim(entry, d, atr, H, L, C, j0, adverse_x=None, trail=1.5, trail_on=0.3, be_on=1.3):
"""adverse_x: None=aus. Sonst schließt sofort, sobald Kurs adverse_x×ATR GEGEN den
Einstieg lief (VOR dem normalen SL/Trailing) — echte Intrabar-Prüfung je Bar."""
eff = entry - d*2.0*atr; hw = entry
end = min(j0+_MAXH, len(C)-1); exit_px = C[end]
for j in range(j0, end+1):
hj, lj = H[j], L[j]
if adverse_x:
adverse_px = entry - d*adverse_x*atr
if (lj <= adverse_px) if d > 0 else (hj >= adverse_px):
return (adverse_px-entry)*d/atr
if (lj <= eff) if d > 0 else (hj >= eff): exit_px = eff; break
hw = max(hw, hj) if d > 0 else min(hw, lj)
prof = (C[j]-entry)*d
if prof >= trail_on*atr:
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)
return (exit_px-entry)*d/atr
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:<20}"
return (f" {lbl:<20} n={s['n']:>4} WR={s['wr']:>3.0f}% ØR={s['oR']:+.3f} "
f"PF={s['pf']:>4.2f} ΣR={s['sum']:>+6.0f}")
def run(H, L, C, A, SP, EF, ES, lo, hi, pop, adverse_x):
"""pop: 'sig' (frischer Cross, |diff|>=dead) oder 'nosig' (WARTEN-Zone, |diff|<dead,
Richtung=Vorzeichen des Diffs)."""
def cost(i, atr): return (SP[i] if SP[i] > 0 else 0.0225)/atr
Rs = []; i = max(lo, _EMA_SLOW + 2)
state_prev = None # (sig|nosig, dir) des letzten Zustands, für "fresh"-Erkennung
while i < hi:
atr = A[i]
if not atr or atr < _ATRMIN:
i += 1; continue
diff = EF[i] - ES[i]; dead = _TREND_DEADBAND * atr
if abs(diff) >= dead:
cur = ("sig", 1 if diff > 0 else -1)
elif diff != 0:
cur = ("nosig", 1 if diff > 0 else -1)
else:
cur = (None, 0)
fresh = cur != state_prev and cur[0] is not None
state_prev = cur
if not fresh or cur[0] != pop:
i += 1; continue
d = cur[1]
entry = C[i]
r, = [sim(entry, d, max(atr, _ATRMIN), H, L, C, i+1, adverse_x)]
Rs.append(r - cost(i, max(atr, _ATRMIN)))
i += _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); EF = _ema(C, _EMA_FAST); ES = _ema(C, _EMA_SLOW)
N = len(C); mid = N//2
print("="*92)
print(f" Früh-Ausstieg NUR ohne Signal-Deckung — {sym} M5 ({N} Bars, 2 Halbjahre, Echtkosten)")
print(f" SIG = frischer EMA-Cross (Signal) · NOSIG = Totband/WARTEN-Zone (kein Signal)")
print("="*92)
for pop_lbl, pop in (("SIG (mit Signal)", "sig"), ("NOSIG (ohne Signal, WARTEN-Zone)", "nosig")):
print(f"\n{pop_lbl}:")
for lbl, lo, hi in (("H1 (alt)", 0, mid), ("H2 (neu)", mid, N-_MAXH-1)):
base = st(run(H, L, C, A, SP, EF, ES, lo, hi, pop, None))
print(f"\n {lbl}:")
print(line("BASE (kein Adverse)", base))
for x in (0.5, 0.75, 1.0, 1.5):
mod = st(run(H, L, C, A, SP, EF, ES, lo, hi, pop, x))
d_ = (mod["sum"] - base["sum"]) if (mod and base) else None
print(line(f"Adverse X={x}×ATR", mod)
+ (f" Δ={d_:+.0f}" if d_ is not None else ""))
print(f"\n Verdict: Adverse-Exit für NOSIG nur übernehmen, wenn EINE Schwelle in")
print(f" BEIDEN Hälften klar über BASE liegt. SIG bleibt so oder so unangetastet.")
if __name__ == "__main__":
main()
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Misst, ob ein Regressions-WINKEL-Filter die nachlaufende EMA-Richtung
verbessert (Problem: SHORT im schon gedrehten Aufwärtstrend, weil EMA12/50
nachläuft). Live-Konfig M5 + M30-EMA-Filter. Getestet wird, ob der M5- bzw.
H1-Winkel als schnellerer Wende-Detektor schlechte Signale herausfiltert.
"""
from __future__ import annotations
import sys
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (WaveRecommender, _atr, _ema_last,
_EMA_FAST, _EMA_SLOW, _N_BARS, _HTF_DEADBAND)
_LR = 14 # ANGLE_LR_BARS
class _NeutralTU:
def snapshot(self): return {"intervals": {}}
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 _rep(name,r):
if not r: print(f" {name:<24} -"); return
n=len(r); w=sum(1 for x in r if x>0)
print(f" {name:<24} n={n:>4} Treffer={100*w/n:>3.0f}% Oe-Edge={sum(r)/n:+.4f}")
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 8000
K=10; dead=2.0 # Winkel-Totband um 90° (Grad)
if not mt5.initialize(): print("init",mt5.last_error()); sys.exit(1)
sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
sym=sym or "SpotCrude"
bars=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,n+_N_BARS+K+5)
m30b=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M30,0,n//6+400)
h1b =mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_H1,0,n//12+400)
mt5.shutdown()
if bars is None or m30b is None or h1b is None: print("Bars fehlen"); sys.exit(1)
T=[int(b["time"]) for b in bars]; H=[float(b["high"]) for b in bars]
L=[float(b["low"]) for b in bars]; C=[float(b["close"]) for b in bars]
mT=[int(b["time"]) for b in m30b]; mc=[float(b["close"]) for b in m30b]
mh=[float(b["high"]) for b in m30b]; ml=[float(b["low"]) for b in m30b]
mEf=_ema_series(mc,_EMA_FAST); mEs=_ema_series(mc,_EMA_SLOW); mA=_atr_series(mh,ml,mc)
hT=[int(b["time"]) for b in h1b]; hc=[float(b["close"]) for b in h1b]
# Winkel-Serien (M5 lokal je Fenster; H1 je Bar)
hAng=[calc_trend_angle(hc[max(0,i-_LR-2):i+1], _LR) if i>=_LR else 90.0 for i in range(len(hc))]
def m30s(ts):
lo,hi,idx=0,len(mT)-1,-1
while lo<=hi:
md=(lo+hi)//2
if mT[md]<=ts: idx=md; lo=md+1
else: hi=md-1
if idx<_EMA_SLOW or mA[idx] is None or mA[idx]<=0: return 0
d=mEf[idx]-mEs[idx]
return 0 if abs(d)<_HTF_DEADBAND*mA[idx] else (1 if d>0 else -1)
def h1ang(ts):
lo,hi,idx=0,len(hT)-1,-1
while lo<=hi:
md=(lo+hi)//2
if hT[md]<=ts: idx=md; lo=md+1
else: hi=md-1
return hAng[idx] if idx>=0 else 90.0
w=WaveRecommender(_NeutralTU(),mt5.TIMEFRAME_M5)
base=[]; m5_ok=[]; m5_no=[]; h1_ok=[]; h1_no=[]; gate=[]
for i in range(_N_BARS,len(C)-K):
wc=C[i-_N_BARS:i]; wh=H[i-_N_BARS:i]; wl=L[i-_N_BARS:i]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: continue
ef=_ema_last(wc,_EMA_FAST); es=_ema_last(wc,_EMA_SLOW)
rec,_=w._build(ef,es,C[i-1],atr,"M5",5,htf_trend=m30s(T[i]))
if rec["signal"]=="WARTEN": continue
d=1 if rec["signal"]=="LONG" else -1
r=(C[i+K]-C[i])*d
base.append(r)
a5=calc_trend_angle(C[i-_LR-2:i], _LR) # M5-Winkel bis Signalbar
ah=h1ang(T[i])
a5_dis = (a5 < 90-dead) if d>0 else (a5 > 90+dead) # Winkel klar dagegen
ah_dis = (ah < 90-dead) if d>0 else (ah > 90+dead)
(m5_no if a5_dis else m5_ok).append(r)
(h1_no if ah_dis else h1_ok).append(r)
if not ah_dis: # Gate: H1-Winkel NICHT klar dagegen
gate.append(r)
print("="*60); print(f" Winkel-vs-EMA-Test — {sym} M5+M30 Vorlauf={K} Totband={dead}°"); print("="*60)
_rep("BASIS (alle)", base)
print("-- M5-Winkel --"); _rep("M5-Winkel dafür/neutral", m5_ok); _rep("M5-Winkel klar dagegen", m5_no)
print("-- H1-Winkel --"); _rep("H1-Winkel dafür/neutral", h1_ok); _rep("H1-Winkel klar dagegen", h1_no)
print("-- Gate: H1-Winkel nicht dagegen --"); _rep("Gate", gate)
if __name__=="__main__":
main()
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""ATR-Floor-Check (Track B): `trailing._ATR_MIN=0.12` stammt aus einer höheren
Vola-Phase — real ist ATR(M1/M5) inzwischen oft 0,070,12, der Floor bindet also
staendig und macht SL/Trail-Distanzen ~1,31,6x breiter als der echte ATR.
Misst die echte Phasen-Trailing-Sim (wie backtest_trailing) mit Floor NUR im Exit
(Signale identisch, raw ATR): floor ∈ {0.00, 0.06, 0.12, 0.25} über 2 Halbjahre.
Metrik in PREIS-Punkten (ΣPts), damit Floor-Varianten fair vergleichbar sind
(R-Normierung würde je Variante anders skalieren). Plus Bind-Quote je Hälfte.
"""
import sys
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (_EMA_FAST, _EMA_SLOW, _N_BARS, _ANGLE_LR, _ANGLE_DEAD,
_REVERSAL_STRETCH, _STRETCH_MAX)
_MAXH=200; _TP_INIT=3.5; _LOCK_START=3.5; _LOCK_SCALE=0.6; _LOCK_MIN=1.2
_BE=1.3; _TRAIL_START=0.3; _MULT=1.5 # Live-Exit-Parameter (M5)
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 sim(entry,d,atr_eff,H,L,C,j0):
"""Live-Phasen-Trailing mit effektivem (ggf. gefloortem) ATR."""
sl=entry-d*2.0*atr_eff; tp=entry+d*_TP_INIT*atr_eff
hw=entry; rank=0
end=min(j0+_MAXH,len(C)-1)
for j in range(j0,end+1):
hi,lo=H[j],L[j]
if (lo<=sl) if d>0 else (hi>=sl): return (sl-entry)*d
if (hi>=tp) if d>0 else (lo<=tp): return (tp-entry)*d
hw=max(hw,hi) if d>0 else min(hw,lo)
profit=(hw-entry)*d
ph=0 if profit<_TRAIL_START*atr_eff else (1 if profit<_LOCK_START*atr_eff else 2)
if ph<rank: ph=rank
rank=ph
if ph==1:
cand=hw-d*_MULT*atr_eff
if profit>=_BE*atr_eff:
cand=max(cand,entry) if d>0 else min(cand,entry)
sl=max(sl,cand) if d>0 else min(sl,cand)
elif ph==2:
tm=max(_LOCK_MIN,_MULT*_LOCK_SCALE)
cand=hw-d*tm*atr_eff
cand=max(cand,entry) if d>0 else min(cand,entry)
sl=max(sl,cand) if d>0 else min(sl,cand)
return (C[end]-entry)*d
def st(v):
if not v: return "n=0"
n=len(v); w=sum(1 for x in v if x>0)
g=sum(x for x in v if x>0); ls=-sum(x for x in v if x<0)
return (f"n={n:>5} WR={100*w/n:>3.0f}% ØPts={sum(v)/n:+.4f} "
f"PF={(g/ls if ls>0 else 99):>4.2f} Worst={min(v):+.3f} ΣPts={sum(v):+.1f}")
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 80000
mt5.initialize(); sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
bars=None
for req in (n,100000,80000,60000,40000):
bars=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,req)
if bars is not None and len(bars)>2000: break
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]
EF=_ema_series(C,_EMA_FAST); ES=_ema_series(C,_EMA_SLOW); AT=_atr_series(H,L,C)
mid=len(C)//2
halves=[("H1 (alt)",_N_BARS,mid),("H2 (neu)",mid,len(C))]
# Signale (raw ATR, identisch für alle Floor-Varianten)
TH=_REVERSAL_STRETCH
SIG={}
for lbl,a,b in halves:
out=[]
for i in range(max(a,_N_BARS), min(b,len(C)-_MAXH-1)):
atr=AT[i]
if not atr or atr<=0: continue
es=ES[i]; ef=EF[i]; stretch=(C[i]-es)/atr
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR); ad=ang-90.0
d=0
if stretch<=-TH and ad>=_ANGLE_DEAD: d=1
elif stretch>=TH and ad<=-_ANGLE_DEAD: d=-1
elif abs(stretch)<_STRETCH_MAX: d=1 if ef>es else -1 if ef<es else 0
if d: out.append((i,d,atr))
SIG[lbl]=out
print("="*96)
print(f" ATR-Floor im Exit — {sym} M5 ({len(C)} Bars) Live-Exit (SL2 · start0,3 · mult1,5 · BE1,3)")
print("="*96)
for lbl,_,_ in halves:
sig=SIG[lbl]
bind12=100*sum(1 for _,_,a in sig if a<0.12)/max(1,len(sig))
bind06=100*sum(1 for _,_,a in sig if a<0.06)/max(1,len(sig))
print(f"\n{lbl}: {len(sig)} Signale · ATR<0,12 bei {bind12:.0f}% · ATR<0,06 bei {bind06:.0f}%")
for floor in (0.0, 0.06, 0.12, 0.25):
v=[sim(C[i],d,max(atr,floor),H,L,C,i+1) for (i,d,atr) in sig]
tag=" ← LIVE" if floor==0.12 else ""
print(f" Floor {floor:4.2f} {st(v)}{tag}")
if __name__=="__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Misst antizyklische BOUNCE-Einstiege (gegen die EMA) bei verschiedenen
Überdehnungs-Schwellen + echter Exit-Sim (SL fix 2,0×ATR + Trailing-TP).
Bounce-LONG : Kurs ueberverkauft (stretch <= -TH unter EMA50) UND Winkel gedreht
(Momentum dreht hoch) -> LONG.
Bounce-SHORT : stretch >= +TH ueber EMA50 UND Winkel dreht runter -> SHORT.
Frage: Bei welcher Schwelle TH traegt der Bounce noch? (Tiefer = mehr Bounces
erwischt, wie der verpasste +1,3-Move; zu tief = Edge kippt.)
Vergleich gegen den reinen Trend-Edge (~+0,20 R) als Benchmark.
"""
import sys
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (_atr, _ema_last, _EMA_FAST, _EMA_SLOW, _N_BARS,
_ANGLE_LR, _ANGLE_DEAD)
_MAXH=240; _TRAILON=0.3; _TPTRAIL=0.5; _ATRMIN=0.12; _SL_ATR=2.0
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 simulate(entry,d,atr,sl,H,L,C,j0):
eff=sl; hw=entry; trail=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]
if (lo<=eff) if d>0 else (hi>=eff): return (eff-entry)*d/atr
hw=max(hw,hi) if d>0 else min(hw,lo)
if (C[j]-entry)*d>=_TRAILON*atr: trail=True
if trail:
lock=hw-d*_TPTRAIL*atr
eff=max(eff,lock) if d>0 else min(eff,lock)
return (exit_px-entry)*d/atr
def stats(Rs):
if not Rs: return " -"
n=len(Rs); w=sum(1 for r in Rs if r>0)
g=sum(r for r in Rs if r>0); ls=-sum(r for r in Rs if r<0)
pf=g/ls if ls>0 else 99.9
return f"n={n:>4} Treffer={100*w/n:>3.0f}% Ø-R={sum(Rs)/n:+.3f} PF={pf:>4.2f} Worst={min(Rs):+.2f} ΣR={sum(Rs):+.0f}"
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 40000
mt5.initialize()
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+_N_BARS+_MAXH+5)
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]
ES=_ema_series(C,_EMA_SLOW); AT=_atr_series(H,L,C)
print("="*92)
print(f" Bounce-Einstieg (antizyklisch) — {sym} M5 Exit: SL {_SL_ATR}×ATR + Trailing-TP pessimistisch")
print(f" Benchmark Trend-Edge ≈ +0,20 R (backtest_rev_exit/exit). Reversal-Schwelle aktuell 3,5×ATR.")
print("="*92)
for TH in (1.5, 2.0, 2.5, 3.0, 3.5):
with_ang=[]; no_ang=[]
for i in range(_N_BARS, len(C)-_MAXH-1):
atr=AT[i]
if not atr or atr<=0: continue
atr=max(atr,_ATRMIN); es=ES[i]
stretch=(C[i]-es)/atr
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR); ad=ang-90.0
# Bounce-LONG: ueberverkauft; Bounce-SHORT: ueberkauft
for d,cond_stretch,cond_ang in ((1, stretch<=-TH, ad>=_ANGLE_DEAD),
(-1, stretch>=TH, ad<=-_ANGLE_DEAD)):
if not cond_stretch: continue
R=simulate(C[i],d,atr,C[i]-d*_SL_ATR*atr,H,L,C,i+1)
no_ang.append(R) # nur Überdehnung
if cond_ang: with_ang.append(R) # + Winkel gedreht (echtes Bounce-Signal)
print(f"\nSchwelle TH={TH}×ATR:")
print(f" nur überdehnt {stats(no_ang)}")
print(f" + Winkel gedreht (BOUNCE) {stats(with_ang)}")
if __name__=="__main__":
main()
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""Verlässlichkeit der Bounce-Anzeige ('aktiv'): Wenn _bounce_one(TF)=active feuert
(überdehnt + Winkel gedreht), wie oft bewegt sich der Kurs in Bounce-Richtung?
Aufgeschlüsselt nach TF (M1/M5/M15/M30) und nach H1-Regime (mit/gegen/flach zur
Bounce-Richtung). Nur ONSET (Übergang nicht-aktiv→aktiv) = unabhängige Ereignisse.
"""
import sys, bisect
import MetaTrader5 as mt5
from core.wave_rec import WaveRecommender, _atr, _ema_last, _EMA_FAST, _EMA_SLOW, _HTF_DEADBAND
K = 10 # Vorlauf in Bars der jeweiligen TF
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 40000
mt5.initialize()
sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
# H1-Regime vorbereiten
h1=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_H1,0,20000)
hT=[int(b["time"]) for b in h1]; hc=[float(b["close"]) for b in h1]
hh=[float(b["high"]) for b in h1]; hl=[float(b["low"]) for b in h1]
hEf=_ema_series(hc,_EMA_FAST); hEs=_ema_series(hc,_EMA_SLOW); hA=_atr_series(hh,hl,hc)
def h1_sign(ts):
idx=bisect.bisect_right(hT,ts)-1
if idx<_EMA_SLOW or hA[idx] is None or hA[idx]<=0: return 0
d=hEf[idx]-hEs[idx]
return 0 if abs(d)<_HTF_DEADBAND*hA[idx] else (1 if d>0 else -1)
tfs=[("M1",mt5.TIMEFRAME_M1),("M5",mt5.TIMEFRAME_M5),
("M15",mt5.TIMEFRAME_M15),("M30",mt5.TIMEFRAME_M30)]
print("="*86)
print(f" Bounce-Anzeige 'aktiv' — Verlässlichkeit je TF & H1-Regime ({sym}, Vorlauf {K} Bars)")
print(" Treffer = Kurs bewegt sich in Bounce-Richtung · Ø = Ø-Bewegung in ATR")
print("="*86)
for lbl,tf in tfs:
r=mt5.copy_rates_from_pos(sym,tf,0,n)
if r is None or len(r)<200: print(f"{lbl}: zu wenig Bars"); continue
T=[int(b["time"]) for b in r]; H=[float(b["high"]) for b in r]
L=[float(b["low"]) for b in r]; C=[float(b["close"]) for b in r]
buckets={"mit":[], "gegen":[], "flach":[], "alle":[]}
prev=False
for i in range(120, len(C)-K):
wc=C[i-119:i+1]; wh=H[i-119:i+1]; wl=L[i-119:i+1]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: prev=False; continue
b=WaveRecommender._bounce_one(wc,atr)
active = bool(b and b["state"]=="active")
if active and not prev:
d=1 if b["dir"]=="LONG" else -1
fwd=(C[i+K]-C[i])*d/atr # in ATR, Bounce-Richtung
reg=h1_sign(T[i])
cls = "mit" if reg==d else "gegen" if reg==-d else "flach"
buckets[cls].append(fwd); buckets["alle"].append(fwd)
prev=active
print(f"\n{lbl} ({len(buckets['alle'])} aktive Bounce-Signale)")
for cls in ("alle","mit","gegen","flach"):
v=buckets[cls]
if not v: print(f" {cls:<6} -"); continue
hit=100*sum(1 for x in v if x>0)/len(v)
print(f" {cls:<6} n={len(v):>4} Treffer={hit:>3.0f}% Ø={sum(v)/len(v):+.2f}×ATR")
mt5.shutdown()
print("\n 'mit' = H1-Trend in Bounce-Richtung (Trend-Fortsetzung)")
print(" 'gegen' = H1-Trend GEGEN den Bounce (Gegen-Trend-Fade)")
print(" 'flach' = H1 neutral (reine Range/Mean-Reversion)")
if __name__=="__main__":
main()
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""ATR-Breakout-Einstiegsfilter (= die Zwei-Konten-Idee auf EINEM Konto):
Signal kommt → Trade erst eröffnen, wenn der Kurs k×ATR in Signalrichtung gelaufen
ist (Bestätigung, 'X dynamisch'). Läuft er vorher k×ATR DAGEGEN → Trade verfällt.
Vergleich gegen Sofort-Einstieg. Exit-Modell = SL 2,0×ATR + Trailing 1,5 (live),
pessimistisch. R = Profit/ATR.
Frage: Hebt das verzögerte Einsteigen den Edge (Qualität) genug, um den k×ATR-
Mehrpreis + die verpassten Trades zu rechtfertigen?
"""
import sys, bisect
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (WaveRecommender, _atr, _ema_last, _EMA_FAST, _EMA_SLOW,
_N_BARS, _HTF_DEADBAND, _ANGLE_LR)
_MAXH=288; _ATRMIN=0.12; _CONFIRM_W=12 # max. Bars, um den Breakout zu bestätigen
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 sim(entry,d,atr,H,L,C,j0, sl_atr=2.0, trail=1.5, trail_on=0.3, be_on=1.0):
eff=entry-d*sl_atr*atr; hw=entry
end=min(j0+_MAXH,len(C)-1); exit_px=C[end]
for j in range(j0,end+1):
hi,lo=H[j],L[j]
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
if prof>=trail_on*atr:
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)
return (exit_px-entry)*d/atr
def rep(name,Rs,nsig):
if not Rs: print(f" {name:<26} -"); return
n=len(Rs); w=sum(1 for x in Rs if x>0)
print(f" {name:<26} Trades={n:>5} ({100*n/nsig:>3.0f}%) Treffer={100*w/n:>3.0f}% "
f"ØR={sum(Rs)/n:+.3f} ΣR={sum(Rs):+.0f}")
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 40000
mt5.initialize()
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+_N_BARS+_MAXH+5)
m30=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M30,0,n//6+500)
mt5.shutdown()
T=[int(b["time"]) for b in bars]; H=[float(b["high"]) for b in bars]
L=[float(b["low"]) for b in bars]; C=[float(b["close"]) for b in bars]
mT=[int(b["time"]) for b in m30]; mc=[float(b["close"]) for b in m30]
mh=[float(b["high"]) for b in m30]; ml=[float(b["low"]) for b in m30]
mEf=_ema_series(mc,_EMA_FAST); mEs=_ema_series(mc,_EMA_SLOW); mA=_atr_series(mh,ml,mc)
def m30s(ts):
idx=bisect.bisect_right(mT,ts)-1
if idx<_EMA_SLOW or mA[idx] is None or mA[idx]<=0: return 0
dd=mEf[idx]-mEs[idx]
return 0 if abs(dd)<_HTF_DEADBAND*mA[idx] else (1 if dd>0 else -1)
class _TU:
def snapshot(self): return {"intervals": {}}
w=WaveRecommender(_TU(), mt5.TIMEFRAME_M5)
sigs=[]
for i in range(_N_BARS, len(C)-_MAXH-1):
wc=C[i-_N_BARS:i]; wh=H[i-_N_BARS:i]; wl=L[i-_N_BARS:i]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: continue
ef=_ema_last(wc,_EMA_FAST); es=_ema_last(wc,_EMA_SLOW)
a5=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR)
rec,_=w._build(ef,es,C[i-1],atr,"M5",0,htf_trend=m30s(T[i]),angle=a5)
if rec["signal"]=="WARTEN": continue
d=1 if rec["signal"]=="LONG" else -1
sigs.append((i,d,max(atr,_ATRMIN)))
nsig=len(sigs)
print("="*82)
print(f" ATR-Breakout-Einstieg — {sym} M5 Signale={nsig} Bestätigungsfenster={_CONFIRM_W} Bars")
print("="*82)
base=[sim(C[i],d,atr,H,L,C,i+1) for (i,d,atr) in sigs]
rep("Sofort-Einstieg (Basis)", base, nsig)
print()
for k in (0.3, 0.5, 1.0, 1.5):
Rs=[]; skipped=0
for (i,d,atr) in sigs:
entry0=C[i]; level=entry0+d*k*atr; invalid=entry0-d*k*atr
je=None
for j in range(i+1, min(i+1+_CONFIRM_W, len(C)-_MAXH-1)):
if d>0:
if L[j]<=invalid: break # erst dagegen → verfällt
if H[j]>=level: je=j; break # Breakout bestätigt
else:
if H[j]>=invalid: break
if L[j]<=level: je=j; break
if je is None: skipped+=1; continue
Rs.append(sim(level,d,atr,H,L,C,je+1))
rep(f"Breakout k={k}×ATR", Rs, nsig)
print(f"\n ØR = Edge je Trade (in ATR) · ΣR = Gesamtertrag · % = Anteil der Signale, die getradet wurden")
if __name__=="__main__":
main()
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Volatilitäts-Squeeze-Breakout (B2, User-Idee 'Breakouterkennung'):
Struktureller Ausbruch aus einer KOMPRESSION — NICHT aus dem EMA-Trend.
Setup: die Spanne der letzten N Bars (Box) ist eng relativ zum ATR (Squeeze);
danach bricht der Kurs k×ATR über/unter die Box → Einstieg in Ausbruchsrichtung.
Exit = live-Modell (SL 2,0×ATR + Trailing 1,5, Breakeven 1,3), pessimistisch.
Kosten = echter Bar-Spread/ATR (Ø ~0,265). 2 Halbjahre. R = Profit/ATR.
Kernfrage (B3): Trägt der SQUEEZE-Filter netto in BEIDEN Hälften — schlägt er den
Ausbruch aus einer BELIEBIGEN Box (ohne Kompression)? Sonst raus.
"""
import sys
import MetaTrader5 as mt5
_MAXH = 288; _ATRMIN = 0.12
_N = 12 # Box-Länge (Bars) = 1 h auf M5
_W = 24 # Fenster nach der Box, in dem der Breakout erfolgen muss (2 h)
_COOL = 12 # Cooldown nach einem Trade (Bars) gegen Überlappung
_K = 0.1 # Ausbruch k×ATR über/unter die Box-Grenze
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])))
out = [None]
for i in range(1, len(C)):
seg = t[max(1, i-p+1):i+1]
out.append(sum(seg)/len(seg))
return out
def sim(entry, d, atr, H, L, C, j0, sl_atr=2.0, trail=1.5, trail_on=0.3, be_on=1.3):
eff = entry - d*sl_atr*atr; hw = entry
end = min(j0+_MAXH, len(C)-1); exit_px = C[end]
for j in range(j0, end+1):
hi, lo = H[j], L[j]
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
if prof >= trail_on*atr:
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)
return (exit_px-entry)*d/atr
def rep(name, Rs):
if not Rs: print(f" {name:<32} -"); return
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
print(f" {name:<32} Trades={n:>4} Treffer={100*w/n:>3.0f}% "
f"ØR={s/n:+.3f} PF={pf:.2f} ΣR={s:+.0f}")
def scan(H, L, C, A, SP, point, lo_i, hi_i, squeeze_mult):
"""Ein Durchlauf: sammelt R (netto) je Breakout. Rs_any = ALLE Box-Ausbrüche,
Rs_sq = nur die mit Squeeze (Box<=squeeze_mult×ATR). i springt nach jedem
Breakout um _COOL vor (Überlappungsschutz) — für beide Listen identisch."""
def cost(i, atr): return (SP[i] if SP[i] > 0 else 0.0225)/atr
Rs_sq = []; Rs_any = []
i = max(lo_i, _N+15)
while i < min(hi_i, len(C)-_MAXH-1):
atr = A[i]
if not atr or atr < _ATRMIN: i += 1; continue
boxHi = max(H[i-_N:i]); boxLo = min(L[i-_N:i]); box = boxHi-boxLo
squeezed = box <= squeeze_mult*atr
hit = None
for j in range(i, min(i+_W, len(C)-_MAXH-1)):
up = boxHi + _K*atr; dn = boxLo - _K*atr
if H[j] >= up: hit = (j, 1, up); break
if L[j] <= dn: hit = (j, -1, dn); break
if hit is None: i += 1; continue
j, d, lvl = hit
R = sim(lvl, d, atr, H, L, C, j+1) - cost(j, atr)
Rs_any.append(R)
if squeezed: Rs_sq.append(R)
i = j + _COOL
return Rs_sq, Rs_any
def main():
n = int(sys.argv[1]) if len(sys.argv) > 1 else 80000
mt5.initialize()
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+30)
si = mt5.symbol_info(sym); point = si.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)
N = len(C); mid = N//2
print("="*88)
print(f" Volatilitäts-Squeeze-Breakout — {sym} M5 ({N} Bars · Box={_N} · Ausbruch={_K}×ATR)")
print(f" Exit=live(SL2,0/Trail1,5/BE1,3) · Kosten=Bar-Spread/ATR · 2 Halbjahre")
print("="*88)
for label, lo, hi in (("H1 (alt)", 0, mid), ("H2 (neu)", mid, N)):
print(f"\n{label}:")
_, any_R = scan(H, L, C, A, SP, point, lo, hi, 99.0)
rep("Ausbruch OHNE Squeeze-Filter", any_R)
for sm in (2.5, 2.0, 1.5):
sq_R, _ = scan(H, L, C, A, SP, point, lo, hi, sm)
rep(f"Squeeze Box<={sm}×ATR", sq_R)
print(f"\n Squeeze trägt nur, wenn ØR & PF in BEIDEN Hälften > 'ohne Filter' UND ØR netto > 0.")
if __name__ == "__main__":
main()
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""ER-Chop-Gate testen (Track B): Signal → WARTEN, wenn Efficiency Ratio der letzten
N Bars < X (choppy). Frage: haben die Chop-Signale (niedrige ER) wirklich schlechteren
Edge als die Trend-Signale (hohe ER)? Über 2 Zeiträume. Exit-Sim SL 2×ATR + Trailing.
Validiert nur, wenn in BEIDEN Hälften: behaltene (ER≥X) besser als alle, verworfene
(ER<X) klar schlechter — sonst schneidet der Gate nur Trades weg ohne Edge-Gewinn.
"""
import sys
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (_EMA_FAST, _EMA_SLOW, _N_BARS, _ANGLE_LR, _ANGLE_DEAD,
_REVERSAL_STRETCH, _STRETCH_MAX)
_MAXH=200; _TRAILON=0.3; _TPTRAIL=0.5; _ATRMIN=0.12; _SL_ATR=2.0
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 simulate(entry,d,atr,sl,H,L,C,j0):
eff=sl; hw=entry; trail=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]
if (lo<=eff) if d>0 else (hi>=eff): return (eff-entry)*d/atr
hw=max(hw,hi) if d>0 else min(hw,lo)
if (C[j]-entry)*d>=_TRAILON*atr: trail=True
if trail:
lock=hw-d*_TPTRAIL*atr
eff=max(eff,lock) if d>0 else min(eff,lock)
return (exit_px-entry)*d/atr
def st(Rs):
if not Rs: return "n=0"
n=len(Rs); w=sum(1 for r in Rs if r>0)
g=sum(r for r in Rs if r>0); ls=-sum(r for r in Rs if r<0)
return f"n={n:>5} WR={100*w/n:>3.0f}% Ø-R={sum(Rs)/n:+.3f} PF={(g/ls if ls>0 else 99):>4.2f} ΣR={sum(Rs):+.0f}"
def er_at(C,i,N):
if i-N<0: return None
net=abs(C[i]-C[i-N]); path=sum(abs(C[j]-C[j-1]) for j in range(i-N+1,i+1))
return net/path if path>0 else 0.0
def collect(H,L,C,ES,EF,AT,lo,hi,N):
TH=_REVERSAL_STRETCH; out=[]
for i in range(max(lo,_N_BARS,N+2), min(hi,len(C)-_MAXH-1)):
atr=AT[i]
if not atr or atr<=0: continue
atr=max(atr,_ATRMIN); es=ES[i]; ef=EF[i]
stretch=(C[i]-es)/atr
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR); ad=ang-90.0
d=0
if stretch<=-TH and ad>=_ANGLE_DEAD: d=1
elif stretch>=TH and ad<=-_ANGLE_DEAD: d=-1
elif abs(stretch)<_STRETCH_MAX: d=1 if ef>es else -1 if ef<es else 0
if not d: continue
R=simulate(C[i],d,atr,C[i]-d*_SL_ATR*atr,H,L,C,i+1)
out.append((er_at(C,i,N), R))
return out
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 80000
N=int(sys.argv[2]) if len(sys.argv)>2 else 20 # ER-Lookback in Bars
mt5.initialize(); sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
bars=None
for req in (n,100000,80000,60000,40000):
bars=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,req)
if bars is not None and len(bars)>2000: break
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]
EF=_ema_series(C,_EMA_FAST); ES=_ema_series(C,_EMA_SLOW); AT=_atr_series(H,L,C)
mid=len(C)//2
halves=[("H1 (alt)",_N_BARS,mid),("H2 (neu)",mid,len(C))]
print("="*90)
print(f" ER-Chop-Gate — {sym} M5 ({len(C)} Bars) ER-Lookback N={N} Bars Exit SL2+Trail")
print("="*90)
EV={lbl: collect(H,L,C,ES,EF,AT,a,b,N) for lbl,a,b in halves}
for lbl,_,_ in halves:
base=[R for _,R in EV[lbl]]
print(f"\n{lbl}: BASELINE (alle Signale) {st(base)}")
for X in (0.15,0.20,0.25,0.30):
keep=[R for er,R in EV[lbl] if er is not None and er>=X]
drop=[R for er,R in EV[lbl] if er is not None and er< X]
print(f" ER≥{X:.2f} behalten {st(keep)}")
print(f" ER<{X:.2f} verworfen {st(drop)}")
print("\n Gate lohnt NUR, wenn 'verworfen' in BEIDEN Hälften deutlich schlechteres")
print(" Ø-R hat als 'behalten' — sonst schneidet er nur Volumen ohne Edge-Gewinn.")
if __name__=="__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Misst, ob die Konfidenz (conf_pct aus _build) den Edge vorhersagt: bringt ein
Mindest-Konfidenz-Gate etwas? Live-Konfig M5 + M30-Filter + Winkel + Tageszeit."""
import sys
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (WaveRecommender, _atr, _ema_last, _EMA_FAST, _EMA_SLOW,
_N_BARS, _HTF_DEADBAND, _ANGLE_LR)
class _TU:
def snapshot(self): return {"intervals": {}}
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 _rep(name,r):
if not r: print(f" {name:<14} -"); return
n=len(r); w=sum(1 for x in r if x>0)
print(f" {name:<14} n={n:>4} Treffer={100*w/n:>3.0f}% Oe-Edge={sum(r)/n:+.4f} Summe={sum(r):+.1f}")
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 12000
K=10
mt5.initialize()
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+_N_BARS+K+5)
m30=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M30,0,n//6+400)
mt5.shutdown()
T=[int(b["time"]) for b in bars]; H=[float(b["high"]) for b in bars]
L=[float(b["low"]) for b in bars]; C=[float(b["close"]) for b in bars]
mT=[int(b["time"]) for b in m30]; mc=[float(b["close"]) for b in m30]
mh=[float(b["high"]) for b in m30]; ml=[float(b["low"]) for b in m30]
mEf=_ema_series(mc,_EMA_FAST); mEs=_ema_series(mc,_EMA_SLOW); mA=_atr_series(mh,ml,mc)
def m30s(ts):
lo,hi,idx=0,len(mT)-1,-1
while lo<=hi:
md=(lo+hi)//2
if mT[md]<=ts: idx=md; lo=md+1
else: hi=md-1
if idx<_EMA_SLOW or mA[idx] is None or mA[idx]<=0: return 0
d=mEf[idx]-mEs[idx]
return 0 if abs(d)<_HTF_DEADBAND*mA[idx] else (1 if d>0 else -1)
w=WaveRecommender(_TU(), mt5.TIMEFRAME_M5)
buckets={'<40':[], '40-54':[], '55-69':[], '70-84':[], '85+':[]}
for i in range(_N_BARS, len(C)-K):
wc=C[i-_N_BARS:i]; wh=H[i-_N_BARS:i]; wl=L[i-_N_BARS:i]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: continue
ef=_ema_last(wc,_EMA_FAST); es=_ema_last(wc,_EMA_SLOW)
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR)
rec,_=w._build(ef,es,C[i-1],atr,"M5",5,htf_trend=m30s(T[i]),angle=ang)
if rec["signal"]=="WARTEN": continue
d=1 if rec["signal"]=="LONG" else -1
r=(C[i+K]-C[i])*d
cf=rec["conf_pct"]
b=('<40' if cf<40 else '40-54' if cf<55 else '55-69' if cf<70 else '70-84' if cf<85 else '85+')
buckets[b].append(r)
print("="*60); print(f" Konfidenz vs Edge — {sym} M5+M30 Vorlauf={K}"); print("="*60)
for b in ('<40','40-54','55-69','70-84','85+'): _rep(b, buckets[b])
# kumuliert ab Schwelle
print("\nKumuliert ab Mindest-Konfidenz:")
order=['<40','40-54','55-69','70-84','85+']; lo=[0,40,55,70,85]
allr=[]
for b in order: allr+=buckets[b]
tot=len(allr)
for k,thr in enumerate(lo):
rr=[]
for j in range(k,len(order)): rr+=buckets[order[j]]
if rr:
win=sum(1 for x in rr if x>0)
print(f" conf>={thr:>2}: n={len(rr):>4} ({100*len(rr)/tot:>3.0f}%) "
f"Treffer={100*win/len(rr):>3.0f}% Oe-Edge={sum(rr)/len(rr):+.4f}")
if __name__=="__main__":
main()
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Misst, ob eigene Indikatoren (MACD / ADX / RSI) als Konfluenz den Edge der
Live-Empfehlung (M5 + M30-Filter) heben — VOR jeder Integration ("erst messen").
Idee: keinen externen Anbieter, sondern lokal aus den MT5-Bars rechnen
(Trend=EMA/ADX, Momentum=MACD/RSI). Pro EMA-Signal wird der Vorlauf-Edge je
Indikator-Übereinstimmung gebucketet + ein kombiniertes Gate getestet.
"""
from __future__ import annotations
import sys
import MetaTrader5 as mt5
from core.wave_rec import (WaveRecommender, _atr, _ema_last,
_EMA_FAST, _EMA_SLOW, _N_BARS, _HTF_DEADBAND)
_TF = {"M1": mt5.TIMEFRAME_M1, "M5": mt5.TIMEFRAME_M5, "M15": mt5.TIMEFRAME_M15,
"M30": mt5.TIMEFRAME_M30, "H1": mt5.TIMEFRAME_H1}
class _NeutralTU:
def snapshot(self): return {"intervals": {}}
def _ema_series(vals, period):
k = 2.0/(period+1); out=[]; e=vals[0]
for i,v in enumerate(vals):
e = v if i==0 else v*k + e*(1-k); out.append(e)
return out
def _atr_series(H,L,C,period=14):
trs=[0.0]
for i in range(1,len(C)):
trs.append(max(H[i]-L[i], abs(H[i]-C[i-1]), abs(L[i]-C[i-1])))
out=[]
for i in range(len(C)):
w=trs[max(1,i-period+1):i+1]; out.append(sum(w)/len(w) if w else None)
return out
def _rsi_series(C, period=14):
n=len(C); out=[None]*n
if n < period+1: return out
ag=al=0.0
for i in range(1, period+1):
ch=C[i]-C[i-1]; ag+=max(ch,0); al+=max(-ch,0)
ag/=period; al/=period
out[period]=100 - 100/(1+(ag/al if al>0 else 1e9))
for i in range(period+1, n):
ch=C[i]-C[i-1]
ag=(ag*(period-1)+max(ch,0))/period
al=(al*(period-1)+max(-ch,0))/period
out[i]=100 - 100/(1+(ag/al if al>0 else 1e9))
return out
def _macd_hist(C, fast=12, slow=26, sig=9):
ef=_ema_series(C,fast); es=_ema_series(C,slow)
line=[ef[i]-es[i] for i in range(len(C))]
signal=_ema_series(line,sig)
return [line[i]-signal[i] for i in range(len(C))]
def _adx(H,L,C,period=14):
"""Wilder ADX. Gibt (adx[], plus_di[], minus_di[]) (None vor Warmup)."""
n=len(C); pdm=[0.0]*n; mdm=[0.0]*n; tr=[0.0]*n
for i in range(1,n):
up=H[i]-H[i-1]; dn=L[i-1]-L[i]
pdm[i]=up if (up>dn and up>0) else 0.0
mdm[i]=dn if (dn>up and dn>0) else 0.0
tr[i]=max(H[i]-L[i], abs(H[i]-C[i-1]), abs(L[i]-C[i-1]))
adx=[None]*n; pdi=[None]*n; mdi=[None]*n
if n < 2*period+1: return adx,pdi,mdi
s_tr=sum(tr[1:period+1]); s_p=sum(pdm[1:period+1]); s_m=sum(mdm[1:period+1])
dxs=[]
for i in range(period, n):
if i>period:
s_tr=s_tr - s_tr/period + tr[i]
s_p =s_p - s_p/period + pdm[i]
s_m =s_m - s_m/period + mdm[i]
p=100*s_p/s_tr if s_tr>0 else 0.0
m=100*s_m/s_tr if s_tr>0 else 0.0
pdi[i]=p; mdi[i]=m
dx=100*abs(p-m)/(p+m) if (p+m)>0 else 0.0
dxs.append((i,dx))
# ADX = Wilder-Glättung der DX
if len(dxs) >= period:
first_bar, _=dxs[period-1]
a=sum(d for _,d in dxs[:period])/period
adx[first_bar]=a
for k in range(period, len(dxs)):
bar,d=dxs[k]
a=(a*(period-1)+d)/period
adx[bar]=a
return adx,pdi,mdi
def _rep(name, rets):
if not rets: print(f" {name:<26} -"); return
n=len(rets); win=sum(1 for x in rets if x>0)
print(f" {name:<26} n={n:>4} Treffer={100*win/n:>3.0f}% Oe-Edge={sum(rets)/n:+.4f}")
def main():
n_bars=int(sys.argv[1]) if len(sys.argv)>1 else 8000
K=10
if not mt5.initialize(): print("init",mt5.last_error()); sys.exit(1)
sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
sym=sym or "SpotCrude"
bars=mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, n_bars+_N_BARS+K+5)
m30b=mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M30, 0, n_bars//6+400)
mt5.shutdown()
if bars is None or m30b is None: print("Bars fehlen"); sys.exit(1)
T=[int(b["time"]) for b in bars]
H=[float(b["high"]) for b in bars]; L=[float(b["low"]) for b in bars]; C=[float(b["close"]) for b in bars]
mT=[int(b["time"]) for b in m30b]; mc=[float(b["close"]) for b in m30b]
mh=[float(b["high"]) for b in m30b]; ml=[float(b["low"]) for b in m30b]
mEf=_ema_series(mc,_EMA_FAST); mEs=_ema_series(mc,_EMA_SLOW); mATR=_atr_series(mh,ml,mc)
rsi=_rsi_series(C); hist=_macd_hist(C); adx,pdi,mdi=_adx(H,L,C)
def m30_sign(ts):
lo,hi,idx=0,len(mT)-1,-1
while lo<=hi:
mid=(lo+hi)//2
if mT[mid]<=ts: idx=mid; lo=mid+1
else: hi=mid-1
if idx<_EMA_SLOW or mATR[idx] is None or mATR[idx]<=0: return 0
d=mEf[idx]-mEs[idx]
return 0 if abs(d)<_HTF_DEADBAND*mATR[idx] else (1 if d>0 else -1)
w=WaveRecommender(_NeutralTU(), mt5.TIMEFRAME_M5)
base=[]; macd_ok=[]; macd_no=[]; di_ok=[]; di_no=[]
adx_strong=[]; adx_weak=[]; rsi_ok=[]; rsi_no=[]; gate=[]
for i in range(_N_BARS, len(C)-K):
j=i-1
if adx[j] is None or rsi[j] is None: continue
wc=C[i-_N_BARS:i]; wh=H[i-_N_BARS:i]; wl=L[i-_N_BARS:i]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: continue
ef=_ema_last(wc,_EMA_FAST); es=_ema_last(wc,_EMA_SLOW)
rec,_=w._build(ef,es,C[j],atr,"M5",5,htf_trend=m30_sign(T[i]))
if rec["signal"]=="WARTEN": continue
d=1 if rec["signal"]=="LONG" else -1
r=(C[i+K]-C[i])*d
base.append(r)
(macd_ok if (hist[j]>0)==(d>0) else macd_no).append(r)
(di_ok if (pdi[j]-mdi[j]>0)==(d>0) else di_no).append(r)
(adx_strong if adx[j]>=25 else adx_weak).append(r)
rsi_aligned = (rsi[j]>50) if d>0 else (rsi[j]<50)
(rsi_ok if rsi_aligned else rsi_no).append(r)
if (hist[j]>0)==(d>0) and adx[j]>=20: # kombiniertes Gate
gate.append(r)
print("="*64)
print(f" Konfluenz-Test — {sym} M5+M30 Vorlauf={K}")
print("="*64)
_rep("BASIS (alle Signale)", base)
print("-- MACD-Histogramm --"); _rep("MACD dafür", macd_ok); _rep("MACD dagegen", macd_no)
print("-- ADX-Richtung (DI) --"); _rep("DI dafür", di_ok); _rep("DI dagegen", di_no)
print("-- ADX-Stärke --"); _rep("ADX>=25 (Trend stark)", adx_strong); _rep("ADX<25 (schwach)", adx_weak)
print("-- RSI 50-Linie --"); _rep("RSI dafür", rsi_ok); _rep("RSI dagegen", rsi_no)
print("-- Gate: MACD dafür & ADX>=20 --"); _rep("Gate", gate)
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Gezielter Test: Konfidenz-STRAFE wenn der M30/H1-Regressionswinkel scharf GEGEN
die Signalrichtung steht (= Markt dreht, aber EMA lagt → falsche 'Konfluenz').
Borderline-Signale fallen dann unter das 55%-Gate (→WARTEN). Kein Pauschal-Filter.
Frage: Sind die so ENTFERNTEN Signale netto negativ (gut weg) und bleibt der
Gesamtertrag der behaltenen ≥ Baseline? Exit-Sim = SL 2,0×ATR + Trailing 1,5 (live).
"""
import sys, bisect
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (WaveRecommender, _atr, _ema_last, _EMA_FAST, _EMA_SLOW,
_N_BARS, _HTF_DEADBAND, _ANGLE_LR, _MIN_CONF)
_MAXH=288; _ATRMIN=0.12
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 sim(entry,d,atr,H,L,C,j0, sl_atr=2.0, trail=1.5, trail_on=0.3, be_on=1.0):
eff=entry-d*sl_atr*atr; hw=entry
end=min(j0+_MAXH,len(C)-1); exit_px=C[end]
for j in range(j0,end+1):
hi,lo=H[j],L[j]
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
if prof>=trail_on*atr:
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)
return (exit_px-entry)*d/atr
def srep(name,r):
if not r: print(f" {name:<34} -"); return
n=len(r); w=sum(1 for x in r if x>0)
print(f" {name:<34} n={n:>5} Treffer={100*w/n:>3.0f}% ØR={sum(r)/n:+.3f} ΣR={sum(r):+.0f}")
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 40000
mt5.initialize()
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+_N_BARS+_MAXH+5)
m30=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M30,0,n//6+500)
h1=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_H1,0,n//12+500)
mt5.shutdown()
T=[int(b["time"]) for b in bars]; H=[float(b["high"]) for b in bars]
L=[float(b["low"]) for b in bars]; C=[float(b["close"]) for b in bars]
mT=[int(b["time"]) for b in m30]; mc=[float(b["close"]) for b in m30]
mh=[float(b["high"]) for b in m30]; ml=[float(b["low"]) for b in m30]
mEf=_ema_series(mc,_EMA_FAST); mEs=_ema_series(mc,_EMA_SLOW); mA=_atr_series(mh,ml,mc)
hT=[int(b["time"]) for b in h1]; hc=[float(b["close"]) for b in h1]
def m30sign(ts):
idx=bisect.bisect_right(mT,ts)-1
if idx<_EMA_SLOW or mA[idx] is None or mA[idx]<=0: return 0
dd=mEf[idx]-mEs[idx]
return 0 if abs(dd)<_HTF_DEADBAND*mA[idx] else (1 if dd>0 else -1)
def ang_at(ts,TT,CC):
idx=bisect.bisect_right(TT,ts)-1
if idx<_ANGLE_LR+2: return 90.0
return calc_trend_angle(CC[idx-_ANGLE_LR-1:idx+1],_ANGLE_LR)
class _TU:
def snapshot(self): return {"intervals": {}}
w=WaveRecommender(_TU(), mt5.TIMEFRAME_M5)
sigs=[] # (i,d,atr,conf,R,m30dev,h1dev)
for i in range(_N_BARS, len(C)-_MAXH-1):
wc=C[i-_N_BARS:i]; wh=H[i-_N_BARS:i]; wl=L[i-_N_BARS:i]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: continue
ef=_ema_last(wc,_EMA_FAST); es=_ema_last(wc,_EMA_SLOW)
a5=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR)
rec,_=w._build(ef,es,C[i-1],atr,"M5",0,htf_trend=m30sign(T[i]),
h1_trend=0,angle=a5)
if rec["signal"]=="WARTEN": continue
d=1 if rec["signal"]=="LONG" else -1
atr=max(atr,_ATRMIN)
R=sim(C[i],d,atr,H,L,C,i+1)
sigs.append((d,rec["conf_pct"],R,ang_at(T[i],mT,mc)-90.0,ang_at(T[i],hT,hc)-90.0))
base=[R for (_,_,R,_,_) in sigs]
print("="*80)
print(f" Konfluenz-Winkel-Strafe — {sym} M5 Signale={len(sigs)} Exit SL2.0+Trail1.5")
print("="*80)
srep("BASELINE (alle Signale)", base)
print(" (Strafe greift, wenn M30- ODER H1-Winkel um >T gegen die Richtung steht)\n")
for T_ in (10, 20, 40):
def opp(d,m,h): return ((d<0 and (m>T_ or h>T_)) or (d>0 and (m<-T_ or h<-T_)))
for P in (15, 25):
kept=[]; removed=[]
for (d,conf,R,m,h) in sigs:
if opp(d,m,h) and (conf - P) < _MIN_CONF:
removed.append(R)
else:
kept.append(R)
print(f"T={T_}° Strafe={P}:")
srep(" entfernt (→WARTEN)", removed)
srep(" behalten (gehandelt)", kept)
db=sum(kept)-sum(base)
print(f" Δ Gesamtertrag vs Baseline: {db:+.0f} R ({'BESSER' if db>0 else 'schlechter'})"
f" · behalten {len(kept)}/{len(sigs)}\n")
if __name__=="__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Soll der REVERSAL-Einstieg vom Dead-Hour-Gate (11-14 Berlin) ausgenommen werden?
Misst Reversal (überdehnt+Winkel gedreht) UND normalen Trend-Einstieg je in/außerhalb
der Dead-Hour, mit echter Exit-Sim (SL 2×ATR + Trailing-TP). Broker-Zeit (UTC+3) →
Berlin via zoneinfo.
Wenn Reversal in der Dead-Hour weiter positiv ist (≈ wie außerhalb), lohnt die Ausnahme;
wenn der Trend-Einstieg in der Dead-Hour negativ bleibt, bleibt das Gate für ihn richtig.
"""
import sys, datetime as dt
from zoneinfo import ZoneInfo
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (_atr, _EMA_FAST, _EMA_SLOW, _N_BARS, _ANGLE_LR, _ANGLE_DEAD,
_REVERSAL_STRETCH, _STRETCH_MAX, _DEAD_HOURS)
_MAXH=240; _TRAILON=0.3; _TPTRAIL=0.5; _ATRMIN=0.12; _SL_ATR=2.0
_BROKER_OFF=3*3600 # Broker = UTC+3
_BERLIN=ZoneInfo("Europe/Berlin")
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 simulate(entry,d,atr,sl,H,L,C,j0):
eff=sl; hw=entry; trail=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]
if (lo<=eff) if d>0 else (hi>=eff): return (eff-entry)*d/atr
hw=max(hw,hi) if d>0 else min(hw,lo)
if (C[j]-entry)*d>=_TRAILON*atr: trail=True
if trail:
lock=hw-d*_TPTRAIL*atr
eff=max(eff,lock) if d>0 else min(eff,lock)
return (exit_px-entry)*d/atr
def stats(Rs):
if not Rs: return " -"
n=len(Rs); w=sum(1 for r in Rs if r>0)
g=sum(r for r in Rs if r>0); ls=-sum(r for r in Rs if r<0)
pf=g/ls if ls>0 else 99.9
return f"n={n:>4} WR={100*w/n:>3.0f}% Ø-R={sum(Rs)/n:+.3f} PF={pf:>4.2f} ΣR={sum(Rs):+.0f}"
def berlin_hour(raw_time):
utc=int(raw_time)-_BROKER_OFF
return dt.datetime.fromtimestamp(utc, tz=dt.timezone.utc).astimezone(_BERLIN).hour
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 40000
TH=_REVERSAL_STRETCH
mt5.initialize()
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+_N_BARS+_MAXH+5)
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]; T=[int(b["time"]) for b in bars]
EF=_ema_series(C,_EMA_FAST); ES=_ema_series(C,_EMA_SLOW); AT=_atr_series(H,L,C)
print("="*90)
print(f" Dead-Hour-Ausnahme für Reversal? — {sym} M5 Exit: SL {_SL_ATR}×ATR + Trailing")
print(f" Dead-Hours={_DEAD_HOURS} (Berlin) · Reversal-Schwelle TH={TH} · Trend nur |stretch|<{_STRETCH_MAX}")
print("="*90)
B={k:[] for k in ("rev_L_dead","rev_L_ok","rev_S_dead","rev_S_ok",
"rev_dead","rev_ok","trend_dead","trend_ok")}
for i in range(_N_BARS, len(C)-_MAXH-1):
atr=AT[i]
if not atr or atr<=0: continue
atr=max(atr,_ATRMIN); es=ES[i]; ef=EF[i]
stretch=(C[i]-es)/atr
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR); ad=ang-90.0
dead = berlin_hour(T[i]) in _DEAD_HOURS
# Reversal
for d,cs,ca,lab in ((1, stretch<=-TH, ad>=_ANGLE_DEAD,"L"),
(-1, stretch>=TH, ad<=-_ANGLE_DEAD,"S")):
if cs and ca:
R=simulate(C[i],d,atr,C[i]-d*_SL_ATR*atr,H,L,C,i+1)
B[f"rev_{lab}_{'dead' if dead else 'ok'}"].append(R)
B[f"rev_{'dead' if dead else 'ok'}"].append(R)
# Trend-Einstieg (EMA-Richtung, NICHT überdehnt) — das, was das Gate blockt
if abs(stretch)<_STRETCH_MAX:
d = 1 if ef>es else -1 if ef<es else 0
if d:
R=simulate(C[i],d,atr,C[i]-d*_SL_ATR*atr,H,L,C,i+1)
B[f"trend_{'dead' if dead else 'ok'}"].append(R)
print("\nREVERSAL gesamt:")
print(f" außerhalb Dead-Hour {stats(B['rev_ok'])}")
print(f" IN Dead-Hour {stats(B['rev_dead'])} ← Kandidat für Ausnahme")
print("\nReversal-LONG (der Fall aus Trade ①):")
print(f" außerhalb Dead-Hour {stats(B['rev_L_ok'])}")
print(f" IN Dead-Hour {stats(B['rev_L_dead'])}")
print("\nReversal-SHORT:")
print(f" außerhalb Dead-Hour {stats(B['rev_S_ok'])}")
print(f" IN Dead-Hour {stats(B['rev_S_dead'])}")
print("\nNormaler TREND-Einstieg (das, was das Gate zu Recht blockt?):")
print(f" außerhalb Dead-Hour {stats(B['trend_ok'])}")
print(f" IN Dead-Hour {stats(B['trend_dead'])}")
if __name__=="__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Verteilung der Empfehlung (Live-Konfig M5 + M30-Filter): wie oft LONG/SHORT
vs WARTEN — und WARUM WARTEN (Totband/Überdehnung/M30-Gegen-Trend)."""
from __future__ import annotations
import sys
import MetaTrader5 as mt5
from core.wave_rec import (WaveRecommender, _atr, _ema_last,
_EMA_FAST, _EMA_SLOW, _N_BARS, _HTF_DEADBAND)
class _NeutralTU:
def snapshot(self): return {"intervals": {}}
def _ema_series(vals, p):
k=2.0/(p+1); out=[]; e=vals[0]
for i,v in enumerate(vals):
e=v if i==0 else v*k+e*(1-k); out.append(e)
return out
def _atr_series(H,L,C,p=14):
trs=[0.0]
for i in range(1,len(C)):
trs.append(max(H[i]-L[i],abs(H[i]-C[i-1]),abs(L[i]-C[i-1])))
return [ (sum(trs[max(1,i-p+1):i+1])/max(1,len(trs[max(1,i-p+1):i+1]))) if i else None for i in range(len(C))]
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 8000
if not mt5.initialize(): print("init",mt5.last_error()); sys.exit(1)
sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
sym=sym or "SpotCrude"
bars=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,n+_N_BARS+20)
m30b=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M30,0,n//6+400)
mt5.shutdown()
if bars is None or m30b is None: print("Bars fehlen"); sys.exit(1)
T=[int(b["time"]) for b in bars]; H=[float(b["high"]) for b in bars]
L=[float(b["low"]) for b in bars]; C=[float(b["close"]) for b in bars]
mT=[int(b["time"]) for b in m30b]; mc=[float(b["close"]) for b in m30b]
mh=[float(b["high"]) for b in m30b]; ml=[float(b["low"]) for b in m30b]
mEf=_ema_series(mc,_EMA_FAST); mEs=_ema_series(mc,_EMA_SLOW); mA=_atr_series(mh,ml,mc)
def m30s(ts):
lo,hi,idx=0,len(mT)-1,-1
while lo<=hi:
md=(lo+hi)//2
if mT[md]<=ts: idx=md; lo=md+1
else: hi=md-1
if idx<_EMA_SLOW or mA[idx] is None or mA[idx]<=0: return 0
d=mEf[idx]-mEs[idx]
return 0 if abs(d)<_HTF_DEADBAND*mA[idx] else (1 if d>0 else -1)
w=WaveRecommender(_NeutralTU(),mt5.TIMEFRAME_M5)
cnt={"LONG":0,"SHORT":0,"WARTEN":0}
why={"Totband":0,"Überdehnung":0,"M30-Gegen-Trend":0,"sonst":0}
tot=0
for i in range(_N_BARS,len(C)):
wc=C[i-_N_BARS:i]; wh=H[i-_N_BARS:i]; wl=L[i-_N_BARS:i]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: continue
ef=_ema_last(wc,_EMA_FAST); es=_ema_last(wc,_EMA_SLOW)
rec,_=w._build(ef,es,C[i-1],atr,"M5",5,htf_trend=m30s(T[i]))
sig=rec["signal"]; cnt[sig]+=1; tot+=1
if sig=="WARTEN":
r=(rec["reasons"] or [""])[0]
if "kein klarer Trend" in r: why["Totband"]+=1
elif "überdehnt" in r: why["Überdehnung"]+=1
elif "M30" in r: why["M30-Gegen-Trend"]+=1
else: why["sonst"]+=1
print("="*52)
print(f" Signal-Verteilung — {sym} M5+M30 ({tot} Bars)")
print("="*52)
for k in ("LONG","SHORT","WARTEN"):
print(f" {k:<8} {cnt[k]:>5} ({100*cnt[k]/tot:>4.1f} %)")
sig_share=100*(cnt['LONG']+cnt['SHORT'])/tot
print(f" → Empfehlung (LONG/SHORT): {sig_share:.1f} % der Zeit")
print(" WARTEN-Gründe:")
wt=cnt["WARTEN"] or 1
for k,v in why.items():
print(f" {k:<16} {v:>5} ({100*v/wt:>4.1f} % der WARTEN)")
if __name__=="__main__":
main()
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Doppeltop / Doppelboden als SIGNAL — Backtest (2026-07-20, User-Idee „Muster-
erkennung"). Das am saubersten mechanisch definierbare Umkehrmuster; nutzt die
bestehende Pivot-Erkennung aus `structure.py` (kausal, Bar für Bar → Signal == was
die Anzeige sehen würde).
Definition:
Doppeltop (→ SHORT): die letzten zwei Swing-HOCHS ~gleich hoch (|h1h2| ≤ tol×ATR),
dazwischen ein Tal (Nackenlinie) mind. `depth`×ATR tiefer; Einstieg wenn der Kurs
FRISCH unter die Nackenlinie bricht (C[i] < neck ≤ C[i1]).
Doppelboden (→ LONG): symmetrisch (zwei ~gleiche Tiefs, Bruch über die Nackenlinie).
Muster verfällt, wenn der Bruch nicht binnen `stale` Bars nach dem 2. Swing kommt.
Sequentielle 1-Positions-Sim, Live-Exit (SL 2,0×ATR + Trailing 1,5 + BE 1,3),
Echtkosten = Bar-Spread/ATR. M30, 2 Halbjahre. Maßstab: Squeeze ØR +0,14…+0,23 &
PF>1 in BEIDEN Hälften. Verdict = beidhälftig robust über die Toleranz-Varianten.
"""
import sys
import MetaTrader5 as mt5
from core.structure import _pivots
_MAXH = 96; _ATRMIN = 0.06; _COOL = 3; _LOOK = 300; _STALE = 40; _DEPTH = 0.5
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:<26} — (keine Trades)"
return (f" {lbl:<26} n={s['n']:>4} WR={s['wr']:>3.0f}% ØR={s['oR']:+.3f} "
f"PF={s['pf']:>4.2f} ΣR={s['sum']:>+6.0f}")
def sim(entry, d, atr, H, L, C, j0):
eff = entry - d*2.0*atr; hw = entry
end = min(j0+_MAXH, len(C)-1); exit_px = C[end]; exit_j = end
for j in range(j0, end+1):
hj, lj = H[j], L[j]
if (lj <= eff) if d > 0 else (hj >= eff):
return (eff-entry)*d/atr, j
hw = max(hw, hj) if d > 0 else min(hw, lj)
prof = (C[j]-entry)*d
if prof >= 0.3*atr:
cand = hw - d*1.5*atr
if prof >= 1.3*atr: cand = max(cand, entry) if d > 0 else min(cand, entry)
eff = max(eff, cand) if d > 0 else min(eff, cand)
return (exit_px-entry)*d/atr, exit_j
def _neckline(pivots, i, atr, tol, want_top):
"""Prüft, ob am Bar i ein Doppeltop (want_top) bzw. -boden fertig ist.
Gibt (True, neckline) zurück, wenn die letzten 2 gleichseitigen Pivots das
Muster bilden. KEIN Bruch-Check hier — nur die Formation."""
kind = "H" if want_top else "L"
same = [p for p in pivots if p[2] == kind]
opp = [p for p in pivots if p[2] != kind]
if len(same) < 2:
return False, None
p1, p2 = same[-2], same[-1]
mids = [q for q in opp if p1[0] < q[0] < p2[0]]
if not mids:
return False, None
# Nackenlinie = Extrem zwischen den beiden gleichseitigen Pivots
neck = (min(mids, key=lambda q: q[1]) if want_top else max(mids, key=lambda q: q[1]))
if abs(p1[1] - p2[1]) > tol * atr: # Schultern ~gleich hoch?
return False, None
depth = (min(p1[1], p2[1]) - neck[1]) if want_top else (neck[1] - max(p1[1], p2[1]))
if depth < _DEPTH * atr: # echtes Tal/Berg dazwischen?
return False, None
if i - p2[0] > _STALE: # zu alt → verfallen
return False, None
return True, neck[1]
def run(H, L, C, A, SP, lo, hi, tol):
def cost(i, atr): return (SP[i] if SP[i] > 0 else 0.0225)/atr
Rs = []; i = max(lo, _LOOK)
while i < hi:
atr = A[i]
if not atr or atr < _ATRMIN:
i += 1; continue
piv = _pivots(H[max(0, i-_LOOK):i+1], L[max(0, i-_LOOK):i+1], 3)
# Index-Offset korrigieren (piv-Indizes sind fensterrelativ)
off = max(0, i-_LOOK)
piv = [(idx+off, pr, k) for (idx, pr, k) in piv]
d = 0; neck = None
ok_t, neck_t = _neckline(piv, i, atr, tol, True)
if ok_t and C[i] < neck_t <= C[i-1]: # frischer Bruch UNTER Nackenlinie
d = -1
else:
ok_b, neck_b = _neckline(piv, i, atr, tol, False)
if ok_b and C[i] > neck_b >= C[i-1]: # frischer Bruch ÜBER Nackenlinie
d = 1
if d == 0:
i += 1; continue
r, xj = sim(C[i], d, max(atr, _ATRMIN), H, L, C, i+1)
Rs.append(r - cost(i, max(atr, _ATRMIN)))
i = xj + _COOL
return Rs
def main():
n = int(sys.argv[1]) if len(sys.argv) > 1 else 60000
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_M30, 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); N = len(C); mid = N//2
print("="*90)
print(f" Doppeltop/-boden als SIGNAL — {sym} M30 ({N} Bars, seq. Sim, Live-Exit, Echtkosten)")
print(f" Einstieg = Nackenlinien-Bruch. Maßstab: Squeeze ØR +0,14…+0,23 & PF>1 BEIDE Hälften.")
print("="*90)
for tol in (0.3, 0.6, 1.0):
s1 = st(run(H, L, C, A, SP, 0, mid, tol))
s2 = st(run(H, L, C, A, SP, mid, N-_MAXH-1, tol))
print(f"\n Schulter-Toleranz {tol}×ATR:")
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()
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Entry-Raum-Gate (Lösung gegen Klein-Close-Trades, 2026-07-16): Kein Entry,
wenn das nächste GEGENLEVEL (Pivot in Trade-Richtung) < X×ATR entfernt ist —
der Ertrag ist dort durch den S/R-Auto-Close gedeckelt (63 % Containment), die
Kosten (real ~0,265×ATR) fressen den Rest → strukturell negativer Erwartungswert.
Sim = Live-Politik: sequentiell (1 Position), Exit via SL 2,0×ATR + Trailing 1,5
+ BE 1,3 UND S/R-Auto-Close (am Gegenlevel, wenn P(break)<0,6 — kalibriertes
Modell aus engine._p_break, Features trainingsgleich M5). Gruppierung der Trades
nach Entry-Distanz zum Gegenlevel. 2 Halbjahre, Kosten = Bar-Spread/ATR.
Gate-Schwelle X nur setzen, wo die Gruppe in BEIDEN Hälften negativ ist.
"""
import sys, bisect
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.engine import _p_break
from core.wave_rec import (WaveRecommender, _atr, _ema_last, _ema_series,
_EMA_FAST, _EMA_SLOW, _N_BARS, _HTF_DEADBAND, _ANGLE_LR)
_MAXH = 288; _ATRMIN = 0.12; _PIV_K = 3; _LOOKBACK = 300; _PTHR = 0.60
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:<26}"
return (f" {lbl:<26} n={s['n']:>4} WR={s['wr']:>3.0f}% ØR={s['oR']:+.3f} "
f"PF={s['pf']:>4.2f} ΣR={s['sum']:>+6.0f}")
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+_N_BARS+_MAXH+5)
m30 = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M30, 0, n//6+500)
h1 = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_H1, 0, n//12+500)
si = mt5.symbol_info(sym); point = si.point
mt5.shutdown()
T = [int(b["time"]) for b in bars]; 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)
def series(rr):
t = [int(b["time"]) for b in rr]; c = [float(b["close"]) for b in rr]
hh = [float(b["high"]) for b in rr]; ll = [float(b["low"]) for b in rr]
return t, _ema_series(c, _EMA_FAST), _ema_series(c, _EMA_SLOW), _atr_series(hh, ll, c)
mT, mEf, mEs, mA = series(m30)
hT, hEf, hEs, hA = series(h1)
def tf_sign(tt, ef, es, aa, ts):
k = bisect.bisect_right(tt, ts)-1
if k < _EMA_SLOW or aa[k] is None or aa[k] <= 0: return 0
dd = ef[k]-es[k]
return 0 if abs(dd) < _HTF_DEADBAND*aa[k] else (1 if dd > 0 else -1)
# Signale je Bar (echte _build-Logik) + volle EMA-Serien für P(break)-Features
w = WaveRecommender(type("T", (), {"snapshot": lambda s: {"intervals": {}}})(), mt5.TIMEFRAME_M5)
EF = _ema_series(C, _EMA_FAST); ES = _ema_series(C, _EMA_SLOW)
sig = [0]*len(C)
for i in range(_N_BARS, len(C)-1):
wc = C[i-_N_BARS:i]; wh = H[i-_N_BARS:i]; wl = L[i-_N_BARS:i]
atr = _atr(wh, wl, wc)
if not atr or atr <= 0: continue
ef = _ema_last(wc, _EMA_FAST); es = _ema_last(wc, _EMA_SLOW)
a5 = calc_trend_angle(C[i-_ANGLE_LR-2:i], _ANGLE_LR)
rec, _ = w._build(ef, es, C[i-1], atr, "M5", 0,
htf_trend=tf_sign(mT, mEf, mEs, mA, T[i]),
h1_trend=tf_sign(hT, hEf, hEs, hA, T[i]), angle=a5)
s_ = rec["signal"]
sig[i] = 1 if s_ == "LONG" else -1 if s_ == "SHORT" else 0
# Pivot-Events: (bestätigt_ab_bar, preis) — Pivot an Bar p ist ab p+_PIV_K bekannt
phE = [(p+_PIV_K, H[p]) for p in range(_PIV_K, len(C)-_PIV_K)
if H[p] == max(H[p-_PIV_K:p+_PIV_K+1])]
plE = [(p+_PIV_K, L[p]) for p in range(_PIV_K, len(C)-_PIV_K)
if L[p] == min(L[p-_PIV_K:p+_PIV_K+1])]
phT = [e[0] for e in phE]; plT = [e[0] for e in plE]
def next_level(i, px, d):
"""Nächstes Gegenlevel (Pivot in Trade-Richtung) aus Bars [i-LOOKBACK, i]."""
if d > 0:
k = bisect.bisect_right(phT, i)
cands = [pr for (t_, pr) in phE[max(0, k-80):k] if t_ >= i-_LOOKBACK and pr > px]
return min(cands) if cands else None
k = bisect.bisect_right(plT, i)
cands = [pr for (t_, pr) in plE[max(0, k-80):k] if t_ >= i-_LOOKBACK and pr < px]
return max(cands) if cands else None
def cost(i, atr): return (SP[i] if SP[i] > 0 else 0.0225)/atr
def run(lo, hi_):
"""Sequentielle Sim mit Live-Exit inkl. S/R-Auto-Close. Liefert
Liste (dist_entry_atr|None, R)."""
out = []
i = lo
while i < hi_:
d = sig[i]
if d == 0:
i += 1; continue
atr = max(A[i] or 0, _ATRMIN)
entry = C[i]
lvl = next_level(i, entry, d)
dist0 = (lvl-entry)*d/atr if lvl else None
eff = entry - d*2.0*atr; hw = entry
end = min(i+_MAXH, len(C)-1); exit_px = C[end]; exit_j = end
for j in range(i+1, end+1):
hj, lj = H[j], L[j]
if (lj <= eff) if d > 0 else (hj >= eff):
exit_px = eff; exit_j = j; break
hw = max(hw, hj) if d > 0 else min(hw, lj)
prof = (C[j]-entry)*d
# S/R-Auto-Close: Touch des Gegenlevels + P(break)<0,6 + im Plus
if lvl is not None and ((hj >= lvl) if d > 0 else (lj <= lvl)):
a_j = max(A[j] or atr, _ATRMIN)
if len(C) > 7 and j >= 7:
mom6 = (C[j]-C[j-6])/a_j*d; mom3 = (C[j]-C[j-3])/a_j*d
wt = 1.0 if (EF[j]-ES[j])*d > 0 else 0.0
p = _p_break(mom6, mom3, wt, abs(lvl-entry)/a_j)
if p < _PTHR and (lvl-entry)*d > 0:
exit_px = lvl; exit_j = j; break
lvl = next_level(j, C[j], d) # Durchbruch → nächstes Level
if prof >= 0.3*atr:
cand = hw - d*1.5*atr
if prof >= 1.3*atr:
cand = max(cand, entry) if d > 0 else min(cand, entry)
eff = max(eff, cand) if d > 0 else min(eff, cand)
out.append((dist0, (exit_px-entry)*d/atr - cost(i, atr)))
i = exit_j + 1
return out
mid = len(C)//2
print("="*92)
print(f" Entry-Raum-Gate — {sym} M5 (seq. Sim, Exit=SL/Trail/BE + S/R-Close P<0,6 · Echtkosten)")
print(f" Gruppen nach Entry-Distanz zum GEGENLEVEL (×ATR). Gate dort, wo BEIDE Hälften rot.")
print("="*92)
B = [(0.0, 0.3), (0.3, 0.6), (0.6, 1.0), (1.0, 2.0), (2.0, 99.0)]
for lbl, lo, hi_ in (("H1 (alt)", _N_BARS, mid), ("H2 (neu)", mid, len(C)-_MAXH-1)):
res = run(lo, hi_)
print(f"\n{lbl}: ({len(res)} Trades)")
print(line("GESAMT", st([r for _, r in res])))
for a, b in B:
grp = [r for dd, r in res if dd is not None and a <= dd < b]
print(line(f"Raum {a:.1f}-{b:.1f}xATR", st(grp)))
print(line("kein Gegenlevel (frei)", st([r for dd, r in res if dd is None])))
if __name__ == "__main__":
main()
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Event-Blackout-Messung (Track B): Sind Signale rund um die planbaren Öl-Events
schlechter? EIA-Lagerbestände = Mittwoch 16:30 Berlin (10:30 ET, Offset ganzjährig 6 h),
API = Dienstag 22:30 Berlin. Misst Signale (Trend+Reversal, Live-Exit-Sim) je Fenster:
eia_pre Mi 15:3016:30 · eia_post Mi 16:3018:00
api_pre Di 21:3022:30 · api_post Di 22:3023:30
gegen 'rest', über 2 History-Hälften. Blackout nur bauen, wenn ein Fenster in BEIDEN
Hälften klar negativ ist (netto, Kosten 0,1×ATR).
"""
import sys, datetime as dt
from zoneinfo import ZoneInfo
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (_EMA_FAST, _EMA_SLOW, _N_BARS, _ANGLE_LR, _ANGLE_DEAD,
_REVERSAL_STRETCH, _STRETCH_MAX)
_MAXH=200; _TRAILON=0.3; _TPTRAIL=0.5; _ATRMIN=0.12; _SL_ATR=2.0; _COST=0.10
_BROKER_OFF=3*3600; _BERLIN=ZoneInfo("Europe/Berlin")
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 simulate(entry,d,atr,sl,H,L,C,j0):
eff=sl; hw=entry; trail=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]
if (lo<=eff) if d>0 else (hi>=eff): return (eff-entry)*d/atr
hw=max(hw,hi) if d>0 else min(hw,lo)
if (C[j]-entry)*d>=_TRAILON*atr: trail=True
if trail:
lock=hw-d*_TPTRAIL*atr
eff=max(eff,lock) if d>0 else min(eff,lock)
return (exit_px-entry)*d/atr
def st(v):
if not v: return "n=0"
n=len(v); w=sum(1 for x in v if x>0)
return (f"n={n:>5} WR={100*w/n:>3.0f}% Ø-R={sum(v)/n:+.3f} "
f"netto={sum(v)/n-_COST:+.3f} ΣR={sum(v):+.0f}")
def bdt(raw):
return dt.datetime.fromtimestamp(int(raw)-_BROKER_OFF, tz=dt.timezone.utc).astimezone(_BERLIN)
def wclass(t):
wd=t.weekday(); hm=t.hour*60+t.minute
if wd==2: # Mittwoch
if 15*60+30<=hm<16*60+30: return "eia_pre"
if 16*60+30<=hm<18*60: return "eia_post"
if wd==1: # Dienstag
if 21*60+30<=hm<22*60+30: return "api_pre"
if 22*60+30<=hm<23*60+30: return "api_post"
return "rest"
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 80000
mt5.initialize(); sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
bars=None
for req in (n,100000,80000,60000,40000):
bars=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,req)
if bars is not None and len(bars)>2000: break
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]; T=[int(b["time"]) for b in bars]
EF=_ema_series(C,_EMA_FAST); ES=_ema_series(C,_EMA_SLOW); AT=_atr_series(H,L,C)
mid=len(C)//2; TH=_REVERSAL_STRETCH
print("="*92)
print(f" Event-Fenster (EIA Mi 16:30 · API Di 22:30 Berlin) — {sym} M5 ({len(C)} Bars)")
print("="*92)
for lbl,a,b in (("H1 (alt)",_N_BARS,mid),("H2 (neu)",mid,len(C))):
B={k:[] for k in ("eia_pre","eia_post","api_pre","api_post","rest")}
for i in range(max(a,_N_BARS), min(b,len(C)-_MAXH-1)):
atr=AT[i]
if not atr or atr<=0: continue
atr=max(atr,_ATRMIN); es=ES[i]; ef=EF[i]
stretch=(C[i]-es)/atr
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR); ad=ang-90.0
d=0
if stretch<=-TH and ad>=_ANGLE_DEAD: d=1
elif stretch>=TH and ad<=-_ANGLE_DEAD: d=-1
elif abs(stretch)<_STRETCH_MAX: d=1 if ef>es else -1 if ef<es else 0
if not d: continue
R=simulate(C[i],d,atr,C[i]-d*_SL_ATR*atr,H,L,C,i+1)
B[wclass(bdt(T[i]))].append(R)
print(f"\n{lbl}:")
for k in ("eia_pre","eia_post","api_pre","api_post","rest"):
print(f" {k:<9} {st(B[k])}")
print("\n Blackout nur, wenn ein Fenster in BEIDEN Hälften klar netto-negativ ist.")
if __name__=="__main__":
main()
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""Exit-Simulation: bringt ein WEITERER Initial-SL netto mehr? (Befund B)
Replays die echten Signale (M5 + M30-Filter) und simuliert den tatsaechlichen
Exit-Ablauf bar-fuer-bar, originalgetreu zu core/trailing.py:
- Initial-SL = X×ATR (die getestete Variable)
- Teil-Exit 50 % bei +1,5×ATR (einmalig)
- Phasen: Init (<0,3×ATR halte Initial-SL) · Trail (SL = HW∓mult×ATR,
Breakeven-Floor ab +0,6×ATR, mult=1,5 fuer M5) · Lock (>=3,5×ATR enger)
- Phasen-Ratsche (nie zurueck)
Pessimistische Intrabar-Annahme: Gegenlauf VOR Mitlauf (zaehlt SL zuerst) —
ueberschaetzt den Nutzen eines weiten SL also NICHT.
PnL in R (= ATR-Vielfache, vergleichbar ueber Trades). Der weite Init-SL wirkt
nur in der Init-Phase: sobald Trailing greift, kappt HW∓1,5×ATR ihn ohnehin.
Hinweis: der feste Init-TP (+3,5×ATR) wird weggelassen — der Runner-Exit laeuft
praktisch ueber den Trailing-SL; das ist die konservative, dominante Mechanik.
"""
from __future__ import annotations
import sys
import MetaTrader5 as mt5
from core.wave_rec import (WaveRecommender, _atr, _ema_last,
_EMA_FAST, _EMA_SLOW, _N_BARS, _HTF_DEADBAND)
_MULT = 1.5 # _MULT_BY_TF[M5]
_BE_ATR = 0.6 # _BREAKEVEN_ATR
_TRAIL_ON = 0.3 # _TRAIL_START_ATR
_LOCK_ATR = 3.5 # _PHASE4_ATR
_LOCK_MULT = max(1.2, _MULT * 0.6)
_PART_ATR = 1.5 # _PARTIAL_TP_ATR
_PART_FRAC = 0.0 # Teil-Exit AUS (entspricht Live: _PARTIAL_TP_FRAC=0)
_ATR_MIN = 0.12
_MAXH = 240 # max. Haltedauer in M5-Bars (~20 h)
class _NeutralTU:
def snapshot(self): return {"intervals": {}}
def _ema_series(vals, period):
k = 2.0/(period+1); out=[]; e=vals[0]
for i,v in enumerate(vals):
e = v if i==0 else v*k + e*(1-k); out.append(e)
return out
def _atr_series(H,L,C,period=14):
trs=[0.0]
for i in range(1,len(C)):
trs.append(max(H[i]-L[i], abs(H[i]-C[i-1]), abs(L[i]-C[i-1])))
out=[]
for i in range(len(C)):
w=trs[max(1,i-period+1):i+1]; out.append(sum(w)/len(w) if w else None)
return out
def _htf_sign_at(ts, T, Ef, Es, ATR):
lo,hi,idx=0,len(T)-1,-1
while lo<=hi:
m=(lo+hi)//2
if T[m]<=ts: idx=m; lo=m+1
else: hi=m-1
if idx<_EMA_SLOW or ATR[idx] is None or ATR[idx]<=0: return 0
d=Ef[idx]-Es[idx]
return 0 if abs(d)<_HTF_DEADBAND*ATR[idx] else (1 if d>0 else -1)
def simulate(entry, d, atr, X, H, L, C, j0, be=_BE_ATR):
"""Ein Trade. Gibt (R_total, stopped_in_init) zurueck. d=+1 long/-1 short.
be = Breakeven-Schwelle in xATR (ab welchem Profit der SL auf Entry rueckt)."""
mult = _MULT
sl = entry - d * X * atr
hw = entry
size = 1.0
realized = 0.0 # in Preis-Einheiten
partial = False
phase_rank = 0 # 0 Init, 1 Trail, 2 Lock
init_stop = 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]
# 1) Gegenlauf zuerst → SL-Treffer?
hit = (lo <= sl) if d > 0 else (hi >= sl)
if hit:
exit_px = sl
if phase_rank == 0:
init_stop = True
break
# 2) HW mit Mitlauf
hw = max(hw, hi) if d > 0 else min(hw, lo)
# 3) Teil-Exit 50 % bei +1,5×ATR (Mitlauf-Extrem)
fav = ((hi if d > 0 else lo) - entry) * d
if not partial and fav >= _PART_ATR * atr:
lvl = entry + d * _PART_ATR * atr
realized += _PART_FRAC * (lvl - entry) * d
size -= _PART_FRAC
partial = True
# 4) Phase aus Close-Profit + Ratsche
prof = (C[j] - entry) * d
rank = 0 if prof < _TRAIL_ON * atr else (1 if prof < _LOCK_ATR * atr else 2)
phase_rank = max(phase_rank, rank)
# 5) Trailing-SL nachziehen
if phase_rank == 1:
cand = hw - d * mult * atr
cand = (max(cand, entry - mult * atr) if d > 0
else min(cand, entry + mult * atr))
if prof >= be * atr:
cand = max(cand, entry) if d > 0 else min(cand, entry)
sl = max(sl, cand) if d > 0 else min(sl, cand)
elif phase_rank == 2:
cand = hw - d * _LOCK_MULT * atr
cand = max(cand, entry) if d > 0 else min(cand, entry)
sl = max(sl, cand) if d > 0 else min(sl, cand)
R = (realized + size * (exit_px - entry) * d) / atr
return R, init_stop
def main():
args = sys.argv[1:]
mode = "be" if (args and args[0] == "be") else "width"
if mode == "be": args = args[1:]
n_bars = int(args[0]) if args else 8000
widths = [1.2, 1.5, 1.8, 2.0, 2.5, 3.0, 4.0]
if not mt5.initialize(): print("init", mt5.last_error()); sys.exit(1)
sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
sym=sym or "SpotCrude"
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, n_bars+_N_BARS+_MAXH+5)
m30b = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M30, 0, n_bars//6+400)
mt5.shutdown()
if bars is None or m30b is None: print("Bars fehlen"); 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]; T=[int(b["time"]) for b in bars]
mT=[int(b["time"]) for b in m30b]; mc=[float(b["close"]) for b in m30b]
mh=[float(b["high"]) for b in m30b]; ml=[float(b["low"]) for b in m30b]
mEf=_ema_series(mc,_EMA_FAST); mEs=_ema_series(mc,_EMA_SLOW); mATR=_atr_series(mh,ml,mc)
w=WaveRecommender(_NeutralTU(), mt5.TIMEFRAME_M5)
# Signale einmal sammeln
sigs=[]
for i in range(_N_BARS, len(C)-_MAXH-1):
wc=C[i-_N_BARS:i]; wh=H[i-_N_BARS:i]; wl=L[i-_N_BARS:i]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: continue
ef=_ema_last(wc,_EMA_FAST); es=_ema_last(wc,_EMA_SLOW)
m=_htf_sign_at(T[i], mT,mEf,mEs,mATR)
rec,_=w._build(ef,es,C[i-1],atr,"M5",5,htf_trend=m)
s=rec["signal"]
if s=="WARTEN": continue
sigs.append((i, 1 if s=="LONG" else -1, max(atr,_ATR_MIN)))
def metrics(Rs):
n=len(Rs); win=sum(1 for r in Rs if r>0)
gains=sum(r for r in Rs if r>0); losses=-sum(r for r in Rs if r<0)
losers=[r for r in Rs if r<0]
pf=gains/losses if losses>0 else float('inf')
avg_loss=sum(losers)/len(losers) if losers else 0.0
return n,win,pf,avg_loss
if mode == "be":
X = 2.0
bes = [0.6, 0.8, 1.0, 1.3, 1.5, 99.0]
print("="*66)
print(f" Breakeven-Test — {sym} M5+M30 SL={X}xATR Signale={len(sigs)}")
print("="*66)
print(f" {'Breakeven':<11}{'Treffer':>8}{'Oe-R':>8}{'Summe-R':>9}"
f"{'PF':>6}{'Oe-Verl.':>9}{'Scratch%':>9}")
for be in bes:
Rs=[]; scratch=0
for (i,d,atr) in sigs:
R,_=simulate(C[i], d, atr, X, H, L, C, i+1, be=be)
Rs.append(R)
if -0.15 < R < 0.05: scratch+=1 # ~Breakeven gescratcht
n,win,pf,avg_loss=metrics(Rs)
lbl = "aus (nie)" if be>10 else f"{be:.1f}"
print(f" {lbl:<11}{100*win/n:>7.0f}%{sum(Rs)/n:>8.3f}{sum(Rs):>9.1f}"
f"{pf:>6.2f}{avg_loss:>9.2f}{100*scratch/n:>8.0f}%")
print("\n Breakeven = ab wieviel xATR Profit der SL auf Entry rückt (aus=nie)")
print(" Scratch% = Anteil ~Breakeven-Ausgänge (R zw. 0,15 und +0,05)")
return
print("="*70)
print(f" Exit-Simulation — {sym} M5+M30 Signale={len(sigs)} Halt<= {_MAXH} Bars")
print(" (Teil-Exit AUS · Breakeven@0.6 · Trail HW∓1.5ATR · pessimistisch)")
print("="*70)
print(f" {'Init-SL':<9}{'Treffer':>8}{'Oe-R':>8}{'Summe-R':>9}{'PF':>6}"
f"{'Oe-Verl.':>9}{'Worst-R':>9}{'Init-Stop':>10}")
for X in widths:
Rs=[]; init_stops=0
for (i,d,atr) in sigs:
R, istop = simulate(C[i], d, atr, X, H, L, C, i+1)
Rs.append(R)
if istop: init_stops+=1
n,win,pf,avg_loss=metrics(Rs)
print(f" {X:<9.1f}{100*win/n:>7.0f}%{sum(Rs)/n:>8.3f}{sum(Rs):>9.1f}"
f"{pf:>6.2f}{avg_loss:>9.2f}{min(Rs):>9.2f}{100*init_stops/n:>9.0f}%")
print("\n Oe-R = Ø/Trade in ATR-Vielfachen · PF = Profit-Faktor")
print(" Oe-Verl. = Ø verlierender Trade · Worst-R = größter Einzelverlust (Tail)")
if __name__ == "__main__":
main()
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Exit-Refactoring: dreht eine andere Exit-Logik die VERLUST-ASYMMETRIE um?
Live-Problem (real gemessen): WR 59%, aber Ø-Gewinn nur ~0,47× Ø-Verlust → PF 0,68.
Ursache: enges Trailing-TP (HW0,5×ATR) kappt Gewinner, fixer 2×ATR-SL lässt
Verlierer voll laufen → kleine Gewinne, große Verluste.
Hier: gleiche Signale, verschiedene Exit-Modelle. Kennzahl = avg_win / avg_loss
(>=1 = Schiefe gedreht) + ΣR/PF. Trailing-STOP-Distanz und Initial-SL variiert.
Pessimistisch (Gegenlauf vor Mitlauf).
"""
import sys
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (WaveRecommender, _atr, _ema_last, _EMA_FAST, _EMA_SLOW,
_N_BARS, _HTF_DEADBAND, _ANGLE_LR)
_MAXH = 288; _ATRMIN = 0.12
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 sim(entry,d,atr,H,L,C,j0, sl_atr, trail, trail_on=0.3, be_on=1.0, tp_atr=None):
"""Trailing-STOP-Modell: Initial-SL sl_atr; ab trail_on Profit Stop = HW∓trail×ATR
(Breakeven-Floor ab be_on). Optionaler harter TP. Rückgabe R."""
eff = entry - d*sl_atr*atr
hw = entry
tp = (entry + d*tp_atr*atr) if tp_atr else None
end=min(j0+_MAXH, len(C)-1); exit_px=C[end]
for j in range(j0,end+1):
hi,lo=H[j],L[j]
# Gegenlauf zuerst (Stop)
if (lo<=eff) if d>0 else (hi>=eff): exit_px=eff; break
# harter TP
if tp is not None and ((hi>=tp) if d>0 else (lo<=tp)): exit_px=tp; break
hw=max(hw,hi) if d>0 else min(hw,lo)
prof=(C[j]-entry)*d
if trail and prof>=trail_on*atr:
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)
return (exit_px-entry)*d/atr
def stats(name, Rs):
if not Rs: print(f" {name:<26} -"); return
n=len(Rs); wins=[r for r in Rs if r>0]; loss=[r for r in Rs if r<=0]
aw=sum(wins)/len(wins) if wins else 0; al=sum(loss)/len(loss) if loss else 0
ratio=(aw/abs(al)) if al<0 else 99
g=sum(wins); ls=-sum(loss); pf=g/ls if ls>0 else 99
print(f" {name:<26} n={n:>4} WR={100*len(wins)/n:>3.0f}% Øgew={aw:+.2f} Øverl={al:+.2f} "
f"Verh={ratio:>4.2f} ØR={sum(Rs)/n:+.3f} PF={pf:>4.2f} ΣR={sum(Rs):+.0f} Worst={min(Rs):+.1f}")
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 40000
mt5.initialize()
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+_N_BARS+_MAXH+5)
m30=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M30,0,n//6+500)
mt5.shutdown()
T=[int(b["time"]) for b in bars]; H=[float(b["high"]) for b in bars]
L=[float(b["low"]) for b in bars]; C=[float(b["close"]) for b in bars]
mT=[int(b["time"]) for b in m30]; mc=[float(b["close"]) for b in m30]
mh=[float(b["high"]) for b in m30]; ml=[float(b["low"]) for b in m30]
mEf=_ema_series(mc,_EMA_FAST); mEs=_ema_series(mc,_EMA_SLOW); mA=_atr_series(mh,ml,mc)
import bisect
def m30s(ts):
idx=bisect.bisect_right(mT,ts)-1
if idx<_EMA_SLOW or mA[idx] is None or mA[idx]<=0: return 0
dd=mEf[idx]-mEs[idx]
return 0 if abs(dd)<_HTF_DEADBAND*mA[idx] else (1 if dd>0 else -1)
class _TU:
def snapshot(self): return {"intervals": {}}
w=WaveRecommender(_TU(), mt5.TIMEFRAME_M5)
sigs=[]
for i in range(_N_BARS, len(C)-_MAXH-1):
wc=C[i-_N_BARS:i]; wh=H[i-_N_BARS:i]; wl=L[i-_N_BARS:i]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: continue
ef=_ema_last(wc,_EMA_FAST); es=_ema_last(wc,_EMA_SLOW)
a5=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR)
rec,_=w._build(ef,es,C[i-1],atr,"M5",0,htf_trend=m30s(T[i]),angle=a5)
if rec["signal"]=="WARTEN": continue
d=1 if rec["signal"]=="LONG" else -1
sigs.append((i,d,max(atr,_ATRMIN)))
print("="*112)
print(f" Exit-Refactoring — {sym} M5 Signale={len(sigs)} (Ziel: Verh = Øgew/Øverl ≥ 1)")
print("="*112)
def run(name,**kw):
Rs=[sim(C[i],d,atr,H,L,C,i+1,**kw) for (i,d,atr) in sigs]
stats(name,Rs)
print("AKTUELL (Problem):")
run("SL2.0 + Trail 0.5 (live)", sl_atr=2.0, trail=0.5, be_on=1.0)
print("\nGewinner laufen lassen (weiteres Trailing):")
run("SL2.0 + Trail 1.0", sl_atr=2.0, trail=1.0, be_on=1.0)
run("SL2.0 + Trail 1.5", sl_atr=2.0, trail=1.5, be_on=1.0)
run("SL2.0 + Trail 2.0", sl_atr=2.0, trail=2.0, be_on=1.0)
print("\nVerlierer enger cutten (kleinerer Initial-SL) + Trailing 1.5:")
run("SL1.0 + Trail 1.5", sl_atr=1.0, trail=1.5, be_on=0.8)
run("SL1.2 + Trail 1.5", sl_atr=1.2, trail=1.5, be_on=0.8)
run("SL1.5 + Trail 1.5", sl_atr=1.5, trail=1.5, be_on=1.0)
print("\nFeste R:R-Ziele (TP erzwingt Symmetrie):")
run("SL1.5 + TP2.25 (RR1.5)", sl_atr=1.5, trail=None, tp_atr=2.25)
run("SL1.5 + TP3.0 (RR2.0)", sl_atr=1.5, trail=None, tp_atr=3.0)
run("SL2.0 + TP4.0 (RR2.0)", sl_atr=2.0, trail=None, tp_atr=4.0)
if __name__=="__main__":
main()
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Auto-Flip-Close (User-Taktik-Kern, 2026-07-16): Position im PLUS automatisch
schließen, sobald die Empfehlung auf die GEGENRICHTUNG dreht (heute nur Alarm).
Sequentielle Trade-Sim (EINE Position wie live): Entry bei Signal, Exit via
SL 2,0×ATR + Trailing 1,5 + BE 1,3 (Basis) — Varianten zusätzlich mit Flip-Close
(nur im Plus; optional Mindestgewinn in ×ATR ~ der '3%-Margin'-Idee; Referenz:
Flip auch im Minus). Signale aus der echten `_build`-Logik (M30-Filter +
H1-Konfluenz + Winkel, hour=None = gate-frei). Kosten = Bar-Spread/ATR.
2 Halbjahre. R = Profit/ATR.
Entscheidung: Auto-Flip-Close nur bauen, wenn ØR & ΣR in BEIDEN Hälften ≥ Basis.
"""
import sys, bisect
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (WaveRecommender, _atr, _ema_last, _ema_series,
_EMA_FAST, _EMA_SLOW, _N_BARS, _HTF_DEADBAND, _ANGLE_LR)
_MAXH = 288; _ATRMIN = 0.12
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 99.9),
sum=s, worst=min(Rs))
def line(lbl, s):
if not s: return f" {lbl:<34}"
return (f" {lbl:<34} n={s['n']:>4} WR={s['wr']:>3.0f}% ØR={s['oR']:+.3f} "
f"PF={s['pf']:>4.2f} ΣR={s['sum']:>+6.0f} Worst={s['worst']:+.1f}")
def run_seq(sig, H, L, C, A, SP, lo, hi, flip=False, min_r=0.0, flip_neg=False):
"""Sequentielle Sim: eine Position, Entry bei Signal, Exit SL/Trail/(Flip)."""
def cost(i, atr): return (SP[i] if SP[i] > 0 else 0.0225)/atr
Rs = []
i = lo
while i < hi:
d = sig[i]
if d == 0:
i += 1; continue
atr = max(A[i] or 0, _ATRMIN)
entry = C[i]
eff = entry - d*2.0*atr; hw = entry
end = min(i+_MAXH, len(C)-1); exit_px = C[end]; exit_j = end
for j in range(i+1, end+1):
hi_, lo_ = H[j], L[j]
if (lo_ <= eff) if d > 0 else (hi_ >= eff):
exit_px = eff; exit_j = j; break
hw = max(hw, hi_) if d > 0 else min(hw, lo_)
prof = (C[j]-entry)*d
# Flip-Close: Signal dreht auf Gegenrichtung → Close zum Bar-Close
if flip and sig[j] == -d and (flip_neg or prof > min_r*atr):
exit_px = C[j]; exit_j = j; break
if prof >= 0.3*atr:
cand = hw - d*1.5*atr
if prof >= 1.3*atr:
cand = max(cand, entry) if d > 0 else min(cand, entry)
eff = max(eff, cand) if d > 0 else min(eff, cand)
Rs.append((exit_px-entry)*d/atr - cost(i, atr))
i = exit_j + 1
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+_N_BARS+_MAXH+5)
m30 = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M30, 0, n//6+500)
h1 = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_H1, 0, n//12+500)
si = mt5.symbol_info(sym); point = si.point
mt5.shutdown()
T = [int(b["time"]) for b in bars]; 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)
def series(rr):
t = [int(b["time"]) for b in rr]; c = [float(b["close"]) for b in rr]
hh = [float(b["high"]) for b in rr]; ll = [float(b["low"]) for b in rr]
return t, _ema_series(c, _EMA_FAST), _ema_series(c, _EMA_SLOW), _atr_series(hh, ll, c)
mT, mEf, mEs, mA = series(m30)
hT, hEf, hEs, hA = series(h1)
def tf_sign(tt, ef, es, aa, ts):
k = bisect.bisect_right(tt, ts)-1
if k < _EMA_SLOW or aa[k] is None or aa[k] <= 0: return 0
dd = ef[k]-es[k]
return 0 if abs(dd) < _HTF_DEADBAND*aa[k] else (1 if dd > 0 else -1)
w = WaveRecommender(type("T", (), {"snapshot": lambda s: {"intervals": {}}})(), mt5.TIMEFRAME_M5)
sig = [0]*len(C)
for i in range(_N_BARS, len(C)-1):
wc = C[i-_N_BARS:i]; wh = H[i-_N_BARS:i]; wl = L[i-_N_BARS:i]
atr = _atr(wh, wl, wc)
if not atr or atr <= 0: continue
ef = _ema_last(wc, _EMA_FAST); es = _ema_last(wc, _EMA_SLOW)
a5 = calc_trend_angle(C[i-_ANGLE_LR-2:i], _ANGLE_LR)
rec, _ = w._build(ef, es, C[i-1], atr, "M5", 0,
htf_trend=tf_sign(mT, mEf, mEs, mA, T[i]),
h1_trend=tf_sign(hT, hEf, hEs, hA, T[i]), angle=a5)
s_ = rec["signal"]
sig[i] = 1 if s_ == "LONG" else -1 if s_ == "SHORT" else 0
mid = len(C)//2
print("="*92)
print(f" Auto-Flip-Close — {sym} M5 (sequentielle 1-Positions-Sim · Exit live · Echtkosten)")
print(f" Flip = Signal dreht auf Gegenrichtung → Close zum Bar-Close (heute nur Alarm)")
print("="*92)
for lbl, lo, hi_ in (("H1 (alt)", _N_BARS, mid), ("H2 (neu)", mid, len(C)-_MAXH-1)):
print(f"\n{lbl}:")
print(line("BASIS (nur SL/Trailing)", st(run_seq(sig, H, L, C, A, SP, lo, hi_))))
print(line("+ Flip-Close im Plus (minR=0)", st(run_seq(sig, H, L, C, A, SP, lo, hi_, flip=True))))
print(line("+ Flip-Close ab +0.3xATR", st(run_seq(sig, H, L, C, A, SP, lo, hi_, flip=True, min_r=0.3))))
print(line("+ Flip-Close ab +0.5xATR", st(run_seq(sig, H, L, C, A, SP, lo, hi_, flip=True, min_r=0.5))))
print(line("+ Flip-Close IMMER (auch Minus)", st(run_seq(sig, H, L, C, A, SP, lo, hi_, flip=True, flip_neg=True))))
print(f"\n Bauen nur, wenn eine Flip-Variante in BEIDEN Hälften ØR & ΣR ≥ BASIS hält.")
if __name__ == "__main__":
main()
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Misst, ob offene D1-Gaps als Magnete einen handelbaren Edge liefern:
Haben Wellen-Signale, die ZUM nächsten offenen Gap zeigen, besseren Edge als die,
die davon WEG zeigen? (Gap-Status wird je Bar korrekt 'as-of' rekonstruiert —
nur Gaps, die zu dem Zeitpunkt offen waren.)
"""
import sys, bisect, datetime as dt
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (WaveRecommender, _atr, _ema_last, _EMA_FAST, _EMA_SLOW,
_N_BARS, _HTF_DEADBAND, _ANGLE_LR)
OFF = 3 * 3600
def _d(ts): return dt.datetime.fromtimestamp(ts - OFF, tz=dt.timezone.utc).date()
class _TU:
def snapshot(self): return {"intervals": {}}
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 _rep(name,r):
if not r: print(f" {name:<30} -"); return
n=len(r); w=sum(1 for x in r if x>0)
print(f" {name:<30} n={n:>5} Treffer={100*w/n:>3.0f}% Ø-Edge={sum(r)/n:+.4f} Summe={sum(r):+.1f}")
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 50000
K=10
mt5.initialize()
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+_N_BARS+K+5)
m30=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M30,0,n//6+500)
d1 =mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_D1,0,500)
mt5.shutdown()
T=[int(b["time"]) for b in bars]; H=[float(b["high"]) for b in bars]
L=[float(b["low"]) for b in bars]; C=[float(b["close"]) for b in bars]
mT=[int(b["time"]) for b in m30]; mc=[float(b["close"]) for b in m30]
mh=[float(b["high"]) for b in m30]; ml=[float(b["low"]) for b in m30]
mEf=_ema_series(mc,_EMA_FAST); mEs=_ema_series(mc,_EMA_SLOW); mA=_atr_series(mh,ml,mc)
def m30sign(ts):
idx=bisect.bisect_right(mT,ts)-1
if idx<_EMA_SLOW or mA[idx] is None or mA[idx]<=0: return 0
d=mEf[idx]-mEs[idx]
return 0 if abs(d)<_HTF_DEADBAND*mA[idx] else (1 if d>0 else -1)
# --- D1-Gaps + Fülldatum bestimmen (alle Jahre in den D1-Daten) ---
dr=[{"date":_d(int(b["time"])),"h":float(b["high"]),"l":float(b["low"])} for b in d1]
gaps=[]
for i in range(1,len(dr)):
p,cur=dr[i-1],dr[i]
if cur["l"]>p["h"]: gaps.append({"date":cur["date"],"dir":"up","lo":p["h"],"hi":cur["l"],"fill":p["h"],"i":i})
elif cur["h"]<p["l"]: gaps.append({"date":cur["date"],"dir":"down","lo":cur["h"],"hi":p["l"],"fill":p["l"],"i":i})
for g in gaps:
fd=None
for j in range(g["i"]+1,len(dr)):
if (g["dir"]=="up" and dr[j]["l"]<=g["lo"]) or (g["dir"]=="down" and dr[j]["h"]>=g["hi"]):
fd=dr[j]["date"]; break
g["fill_date"]=fd
print(f"D1-Gaps gesamt: {len(gaps)}")
def open_gaps_asof(d):
return [g for g in gaps if g["date"]<=d and (g["fill_date"] is None or g["fill_date"]>d)]
w=WaveRecommender(_TU(), mt5.TIMEFRAME_M5)
toward=[]; away=[]; nogap=[]
toward_r=[]; away_r=[] # nur Gaps "in Reichweite" (≤2% vom Kurs)
for i in range(_N_BARS, len(C)-K):
wc=C[i-_N_BARS:i]; wh=H[i-_N_BARS:i]; wl=L[i-_N_BARS:i]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: continue
ef=_ema_last(wc,_EMA_FAST); es=_ema_last(wc,_EMA_SLOW)
a5=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR)
rec,_=w._build(ef,es,C[i-1],atr,"M5",0,htf_trend=m30sign(T[i]),angle=a5)
if rec["signal"]=="WARTEN": continue
d=1 if rec["signal"]=="LONG" else -1
fwd=(C[i+K]-C[i])*d
px=C[i]; og=open_gaps_asof(_d(T[i]))
if not og:
nogap.append(fwd); continue
nearest=min(og, key=lambda g: abs(g["fill"]-px))
side=1 if nearest["fill"]>px else -1 # +1 Gap oben, -1 Gap unten
(toward if d==side else away).append(fwd)
if abs(nearest["fill"]-px)/px <= 0.02: # in Reichweite
(toward_r if d==side else away_r).append(fwd)
print("="*72)
print(f" Gap-Magnet-Test — {sym} M5 Vorlauf={K} (Signal zum nächsten offenen Gap?)")
print("="*72)
_rep("Signal ZUM Gap (toward)", toward)
_rep("Signal WEG vom Gap (away)", away)
_rep("kein offenes Gap", nogap)
print("\n Nur Gaps in Reichweite (≤2% vom Kurs):")
_rep(" toward (≤2%)", toward_r)
_rep(" away (≤2%)", away_r)
if __name__=="__main__":
main()
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Macht das Dead-Hour-Gate (11-14 Berlin) Sinn? Stunde-für-Stunde die realisierte
R dessen, was der Bot REAL handeln würde: Reversal (überdehnt+Winkel) sonst Trend
(EMA-Richtung, nicht überdehnt). Exit-Sim SL 2×ATR + Trailing. Netto = Ø-R Kosten
(~0,1×ATR). Wenn 11-14 klar negativ/schlechtester Block → Gate berechtigt; wenn nur
mild schwächer & netto positiv → Gate wirft Gewinn weg.
"""
import sys, datetime as dt
from zoneinfo import ZoneInfo
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (_EMA_FAST, _EMA_SLOW, _N_BARS, _ANGLE_LR, _ANGLE_DEAD,
_REVERSAL_STRETCH, _STRETCH_MAX, _DEAD_HOURS)
_MAXH=240; _TRAILON=0.3; _TPTRAIL=0.5; _ATRMIN=0.12; _SL_ATR=2.0; _COST=0.10
_BROKER_OFF=3*3600; _BERLIN=ZoneInfo("Europe/Berlin")
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 simulate(entry,d,atr,sl,H,L,C,j0):
eff=sl; hw=entry; trail=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]
if (lo<=eff) if d>0 else (hi>=eff): return (eff-entry)*d/atr
hw=max(hw,hi) if d>0 else min(hw,lo)
if (C[j]-entry)*d>=_TRAILON*atr: trail=True
if trail:
lock=hw-d*_TPTRAIL*atr
eff=max(eff,lock) if d>0 else min(eff,lock)
return (exit_px-entry)*d/atr
def berlin_hour(raw):
return dt.datetime.fromtimestamp(int(raw)-_BROKER_OFF, tz=dt.timezone.utc).astimezone(_BERLIN).hour
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 40000
TH=_REVERSAL_STRETCH
mt5.initialize()
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+_N_BARS+_MAXH+5)
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]; T=[int(b["time"]) for b in bars]
EF=_ema_series(C,_EMA_FAST); ES=_ema_series(C,_EMA_SLOW); AT=_atr_series(H,L,C)
by={h:[] for h in range(24)}
for i in range(_N_BARS, len(C)-_MAXH-1):
atr=AT[i]
if not atr or atr<=0: continue
atr=max(atr,_ATRMIN); es=ES[i]; ef=EF[i]
stretch=(C[i]-es)/atr
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR); ad=ang-90.0
d=0
if stretch<=-TH and ad>=_ANGLE_DEAD: d=1 # Reversal-LONG
elif stretch>=TH and ad<=-_ANGLE_DEAD: d=-1 # Reversal-SHORT
elif abs(stretch)<_STRETCH_MAX: d = 1 if ef>es else -1 if ef<es else 0 # Trend
if not d: continue
R=simulate(C[i],d,atr,C[i]-d*_SL_ATR*atr,H,L,C,i+1)
by[berlin_hour(T[i])].append(R)
print("="*78)
print(f" Realisierte R je Berlin-Stunde — {sym} M5 (Trend+Reversal, Exit SL2+Trail)")
print(f" Netto = Ø-R Kosten {_COST}×ATR. Dead-Hours aktuell = {_DEAD_HOURS}")
print("="*78)
print(f" {'Std':>3} {'n':>6} {'WR':>4} {'Ø-R':>7} {'netto':>7} {'ΣR':>7} Bar")
allnet=0
for h in range(24):
v=by[h]
if not v: continue
wr=100*sum(1 for r in v if r>0)/len(v); avg=sum(v)/len(v); net=avg-_COST
allnet+=net*len(v)
mark=" « DEAD" if h in _DEAD_HOURS else ""
bar=('+'*int(net*60)) if net>0 else ('-'*int(-net*60))
print(f" {h:>3} {len(v):>6} {wr:>3.0f}% {avg:>+7.3f} {net:>+7.3f} {sum(v):>+7.0f} {bar}{mark}")
dead=[r for h in _DEAD_HOURS for r in by[h]]
ok=[r for h in range(24) if h not in _DEAD_HOURS for r in by[h]]
def s(v):
return f"n={len(v)} Ø-R={sum(v)/len(v):+.3f} netto={sum(v)/len(v)-_COST:+.3f} ΣR={sum(v):+.0f}"
print(f"\n DEAD (11-14): {s(dead)}")
print(f" Rest : {s(ok)}")
if __name__=="__main__":
main()
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Verifiziert Gate-Option A ('nur 12+16 blocken' statt '11-14') auf ZWEI unabhängigen
Zeiträumen (History-Hälften). Prüft: (1) sind Std 12 & 16 in BEIDEN Hälften negativ,
Std 13 nicht? (Sign-Stabilität = kein Overfitting). (2) schlägt Gate A das aktuelle
Gate (11-14) und Kein-Gate im Gesamt-Netto-R — in BEIDEN Hälften?
"""
import sys, datetime as dt
from zoneinfo import ZoneInfo
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (_EMA_FAST, _EMA_SLOW, _N_BARS, _ANGLE_LR, _ANGLE_DEAD,
_REVERSAL_STRETCH, _STRETCH_MAX)
_MAXH=240; _TRAILON=0.3; _TPTRAIL=0.5; _ATRMIN=0.12; _SL_ATR=2.0; _COST=0.10
_BROKER_OFF=3*3600; _BERLIN=ZoneInfo("Europe/Berlin")
GATE_CUR={11,12,13,14}; GATE_A={12,16}
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 simulate(entry,d,atr,sl,H,L,C,j0):
eff=sl; hw=entry; trail=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]
if (lo<=eff) if d>0 else (hi>=eff): return (eff-entry)*d/atr
hw=max(hw,hi) if d>0 else min(hw,lo)
if (C[j]-entry)*d>=_TRAILON*atr: trail=True
if trail:
lock=hw-d*_TPTRAIL*atr
eff=max(eff,lock) if d>0 else min(eff,lock)
return (exit_px-entry)*d/atr
def bhour(raw):
return dt.datetime.fromtimestamp(int(raw)-_BROKER_OFF, tz=dt.timezone.utc).astimezone(_BERLIN).hour
def collect(H,L,C,T,EF,ES,AT,lo,hi):
"""→ Liste (berlin_hour, R) für Bars in [lo,hi)."""
TH=_REVERSAL_STRETCH; out=[]
for i in range(max(lo,_N_BARS), min(hi,len(C)-_MAXH-1)):
atr=AT[i]
if not atr or atr<=0: continue
atr=max(atr,_ATRMIN); es=ES[i]; ef=EF[i]
stretch=(C[i]-es)/atr
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR); ad=ang-90.0
d=0
if stretch<=-TH and ad>=_ANGLE_DEAD: d=1
elif stretch>=TH and ad<=-_ANGLE_DEAD: d=-1
elif abs(stretch)<_STRETCH_MAX: d=1 if ef>es else -1 if ef<es else 0
if not d: continue
out.append((bhour(T[i]), simulate(C[i],d,atr,C[i]-d*_SL_ATR*atr,H,L,C,i+1)))
return out
def policy(ev, blocked):
kept=[r for h,r in ev if h not in blocked]
net=sum(r-_COST for r in kept)
return len(kept), net, (net/len(kept) if kept else 0)
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 120000
mt5.initialize()
sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
bars=None
for req in (n, 100000, 80000, 60000, 40000):
bars=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,req)
if bars is not None and len(bars)>1000: break
mt5.shutdown()
if bars is None: print("Keine Bars von MT5."); return
print(f" {len(bars)} M5-Bars geladen.")
H=[float(b["high"]) for b in bars]; L=[float(b["low"]) for b in bars]
C=[float(b["close"]) for b in bars]; T=[int(b["time"]) for b in bars]
EF=_ema_series(C,_EMA_FAST); ES=_ema_series(C,_EMA_SLOW); AT=_atr_series(H,L,C)
mid=len(C)//2
def span(a,b): return f"{dt.datetime.utcfromtimestamp(T[a]-_BROKER_OFF):%d.%m.%y}{dt.datetime.utcfromtimestamp(T[b-1]-_BROKER_OFF):%d.%m.%y}"
halves=[("H1 (alt)",_N_BARS,mid),("H2 (neu)",mid,len(C))]
print("="*82)
print(f" Gate-A-Verifikation auf 2 Zeiträumen — {sym} M5 (netto = Ø-R {_COST})")
print("="*82)
EV={}
for lbl,a,b in halves:
EV[lbl]=collect(H,L,C,T,EF,ES,AT,a,b)
print(f" {lbl}: {span(a,b)} ({len(EV[lbl])} Signale)")
# (1) Sign-Stabilität der Schlüsselstunden
print("\n(1) Netto-R je Stunde — sind 12 & 16 in BEIDEN negativ, 13 nicht?")
print(f" {'Std':>3} " + "".join(f"{lbl:>16}" for lbl,_,_ in halves))
for h in (11,12,13,14,15,16,17):
cells=""
for lbl,_,_ in halves:
v=[r for hh,r in EV[lbl] if hh==h]
cells+=f"{(sum(v)/len(v)-_COST):>+10.3f}(n{len(v)})" if v else f"{'-':>16}"
flag=""
if h in GATE_A: flag=" ← blocken(A)"
if h==13: flag=" ← behalten?"
print(f" {h:>3} {cells}{flag}")
# (2) Politik-Vergleich je Hälfte
print("\n(2) Gesamt-Netto-R je Gate-Politik (höher = besser), je Hälfte:")
print(f" {'Politik':<16}" + "".join(f"{lbl:>20}" for lbl,_,_ in halves))
for name,blk in (("Kein Gate",set()),("Aktuell 11-14",GATE_CUR),("A: 12+16",GATE_A)):
cells=""
for lbl,_,_ in halves:
k,net,avg=policy(EV[lbl],blk)
cells+=f" ΣR{net:>+7.0f} (n{k}{avg:+.3f})"
print(f" {name:<16}{cells}")
print("\n Gate A validiert NUR, wenn es in BEIDEN Hälften ΣR > 'Aktuell 11-14' liefert")
print(" UND 12+16 in beiden negativ, 13 in beiden nicht-negativ sind.")
if __name__=="__main__":
main()
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Misst einen Höher-TF-WINKEL-Filter: Signal verwerfen (→WARTEN), wenn der
M30- UND/ODER H1-Regressionswinkel klar GEGEN die Signalrichtung steht (führender
Winkel statt nachlaufender EMA). Genau der Fall, der die 37/10-Shorts erzeugte:
Welle short, aber M30/H1-Winkel stark aufwärts.
Frage: Haben die so entfernten Signale negativen Edge (gut weg) und trägt der
Gesamtertrag der behaltenen Signale? (Pauschal-Filter kann auch Gewinner killen.)
"""
import sys, bisect
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (WaveRecommender, _atr, _ema_last, _EMA_FAST, _EMA_SLOW,
_N_BARS, _HTF_DEADBAND, _ANGLE_LR)
class _TU:
def snapshot(self): return {"intervals": {}}
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 _rep(name, r):
if not r: print(f" {name:<30} -"); return
n=len(r); w=sum(1 for x in r if x>0)
print(f" {name:<30} n={n:>4} Treffer={100*w/n:>3.0f}% Ø-Edge={sum(r)/n:+.4f} Summe={sum(r):+.1f}")
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 12000
K=10
mt5.initialize()
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+_N_BARS+K+5)
m30=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M30,0,n//6+400)
h1 =mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_H1,0,n//12+400)
mt5.shutdown()
T=[int(b["time"]) for b in bars]; H=[float(b["high"]) for b in bars]
L=[float(b["low"]) for b in bars]; C=[float(b["close"]) for b in bars]
mT=[int(b["time"]) for b in m30]; mc=[float(b["close"]) for b in m30]
mh=[float(b["high"]) for b in m30]; ml=[float(b["low"]) for b in m30]
mEf=_ema_series(mc,_EMA_FAST); mEs=_ema_series(mc,_EMA_SLOW); mA=_atr_series(mh,ml,mc)
hT=[int(b["time"]) for b in h1]; hc=[float(b["close"]) for b in h1]
def m30sign(ts):
idx=bisect.bisect_right(mT,ts)-1
if idx<_EMA_SLOW or mA[idx] is None or mA[idx]<=0: return 0
d=mEf[idx]-mEs[idx]
return 0 if abs(d)<_HTF_DEADBAND*mA[idx] else (1 if d>0 else -1)
def ang_at(ts, TT, CC):
idx=bisect.bisect_right(TT,ts)-1
if idx<_ANGLE_LR+2: return 90.0
return calc_trend_angle(CC[idx-_ANGLE_LR-1:idx+1], _ANGLE_LR)
w=WaveRecommender(_TU(), mt5.TIMEFRAME_M5)
sigs=[] # (d, fwd, m30dev, h1dev) dev = Winkel-90 (>0 auf, <0 ab)
for i in range(_N_BARS, len(C)-K):
wc=C[i-_N_BARS:i]; wh=H[i-_N_BARS:i]; wl=L[i-_N_BARS:i]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: continue
ef=_ema_last(wc,_EMA_FAST); es=_ema_last(wc,_EMA_SLOW)
a5=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR)
rec,_=w._build(ef,es,C[i-1],atr,"M5",0,htf_trend=m30sign(T[i]),angle=a5)
if rec["signal"]=="WARTEN": continue
d=1 if rec["signal"]=="LONG" else -1
fwd=(C[i+K]-C[i])*d
sigs.append((d, fwd, ang_at(T[i],mT,mc)-90.0, ang_at(T[i],hT,hc)-90.0))
base=[f for (_,f,_,_) in sigs]
print("="*78)
print(f" Höher-TF-Winkel-Filter — {sym} M5 Signale={len(sigs)} Vorlauf={K}")
print("="*78)
_rep("BASELINE (alle Signale)", base)
print(" dev = Regressionswinkel 90 (>0 aufwärts, <0 abwärts)\n")
for T_ in (2, 10, 20, 40):
# "gegen die Richtung": Short & Winkel auf (dev>+T) bzw. Long & Winkel ab (dev<T)
def against(d, dev): return (d<0 and dev> T_) or (d>0 and dev< -T_)
for mode in ("BEIDE", "EINER"):
removed=[]; kept=[]
for (d,f,m,h) in sigs:
am=against(d,m); ah=against(d,h)
cut = (am and ah) if mode=="BEIDE" else (am or ah)
(removed if cut else kept).append(f)
print(f"Schwelle T={T_}° · '{mode} TF gegen Signal':")
_rep(" entfernt (würde →WARTEN)", removed)
_rep(" behalten (gehandelt)", kept)
db=(sum(kept)-sum(base))
print(f" Δ Gesamtertrag vs Baseline: {db:+.1f} "
f"({'BESSER' if db>0 else 'schlechter'}) · behalten {len(kept)}/{len(sigs)}")
print()
if __name__=="__main__":
main()
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Misst den Effekt eines Higher-TF-Trendfilters auf die Empfehlung.
Vergleicht den Richtungs-Edge der echten _build-Logik OHNE Filter gegen
MIT Filter: Signal wird verworfen, wenn der uebergeordnete Trend (H1 bzw. M30,
EMA12 vs EMA50) klar dagegen steht (Totband in xATR der HTF).
"""
from __future__ import annotations
import sys
import MetaTrader5 as mt5
from core.wave_rec import (WaveRecommender, _atr, _ema_last,
_EMA_FAST, _EMA_SLOW, _N_BARS)
_TF = {"M1": mt5.TIMEFRAME_M1, "M5": mt5.TIMEFRAME_M5, "M15": mt5.TIMEFRAME_M15,
"M30": mt5.TIMEFRAME_M30, "H1": mt5.TIMEFRAME_H1}
class _NeutralTU:
def snapshot(self):
return {"intervals": {}}
def _ema_series(vals, period):
"""Komplette EMA-Reihe (gleiche Laenge wie vals)."""
k = 2.0 / (period + 1)
out = []
e = vals[0]
for i, v in enumerate(vals):
e = v if i == 0 else v * k + e * (1.0 - k)
out.append(e)
return out
def _rep(name, rets):
if not rets:
print(f" {name:<16} keine Signale"); return
n = len(rets); win = sum(1 for x in rets if x > 0)
print(f" {name:<16} n={n:>4} Treffer={100*win/n:>3.0f}% "
f"Oe-Edge={sum(rets)/n:+.4f} Summe={sum(rets):+.2f}")
def main():
base_lbl = (sys.argv[1].upper() if len(sys.argv) > 1 else "M5")
htf_lbl = (sys.argv[2].upper() if len(sys.argv) > 2 else "H1")
n_bars = int(sys.argv[3]) if len(sys.argv) > 3 else 4000
dead = float(sys.argv[4]) if len(sys.argv) > 4 else 0.10 # HTF-Totband xATR
K = 10
tf, htf = _TF[base_lbl], _TF[htf_lbl]
if not mt5.initialize():
print("MT5-Init fehlgeschlagen:", mt5.last_error()); sys.exit(1)
sym = None
for cand in ("SpotCrude", "USOIL", "WTI", "XTIUSD"):
if mt5.symbol_info(cand):
sym = cand; break
sym = sym or "SpotCrude"
bars = mt5.copy_rates_from_pos(sym, tf, 0, n_bars + _N_BARS + K + 5)
hbars = mt5.copy_rates_from_pos(sym, htf, 0, n_bars // 2 + 300)
mt5.shutdown()
if bars is None or hbars is None:
print("Zu wenige Bars."); sys.exit(1)
T = [int(b["time"]) for b in bars]
H = [float(b["high"]) for b in bars]
L = [float(b["low"]) for b in bars]
C = [float(b["close"]) for b in bars]
hT = [int(b["time"]) for b in hbars]
hC = [float(b["close"]) for b in hbars]
hH = [float(b["high"]) for b in hbars]
hL = [float(b["low"]) for b in hbars]
hEf = _ema_series(hC, _EMA_FAST)
hEs = _ema_series(hC, _EMA_SLOW)
def htf_trend(ts):
"""Vorzeichen des HTF-Trends zum Zeitpunkt ts: +1 auf, -1 ab, 0 flach."""
# letzte HTF-Bar mit time <= ts
lo, hi = 0, len(hT) - 1
idx = -1
while lo <= hi:
mid = (lo + hi) // 2
if hT[mid] <= ts:
idx = mid; lo = mid + 1
else:
hi = mid - 1
if idx < _EMA_SLOW:
return 0
atr = _atr(hH[:idx+1], hL[:idx+1], hC[:idx+1])
if not atr:
return 0
d = hEf[idx] - hEs[idx]
if abs(d) < dead * atr:
return 0
return 1 if d > 0 else -1
w = WaveRecommender(_NeutralTU(), tf)
raw = {"LONG": [], "SHORT": []}
flt = {"LONG": [], "SHORT": []}
dropped = 0
for i in range(_N_BARS, len(C) - K):
win_c = C[i-_N_BARS:i]; win_h = H[i-_N_BARS:i]; win_l = L[i-_N_BARS:i]
atr = _atr(win_h, win_l, win_c)
if not atr or atr <= 0:
continue
ef = _ema_last(win_c, _EMA_FAST); es = _ema_last(win_c, _EMA_SLOW)
rec, _ = w._build(ef, es, C[i-1], atr, base_lbl, 5)
sig = rec["signal"]
if sig == "WARTEN":
continue
fwd = C[i+K] - C[i]
r = fwd if sig == "LONG" else -fwd
raw[sig].append(r)
ht = htf_trend(T[i])
d = 1 if sig == "LONG" else -1
if ht != 0 and ht != d: # HTF steht klar dagegen → verwerfen
dropped += 1
continue
flt[sig].append(r)
print("=" * 66)
print(f" HTF-Filter — {sym} Basis={base_lbl} Filter={htf_lbl} "
f"Totband={dead}xATR Vorlauf={K}")
print("=" * 66)
print("OHNE Filter:")
_rep("LONG", raw["LONG"]); _rep("SHORT", raw["SHORT"])
_rep("ALLE", raw["LONG"] + raw["SHORT"])
print(f"MIT Filter (verworfen: {dropped}):")
_rep("LONG", flt["LONG"]); _rep("SHORT", flt["SHORT"])
_rep("ALLE", flt["LONG"] + flt["SHORT"])
if __name__ == "__main__":
main()
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Misst vier Verbesserungs-Ideen in einem Lauf (Live-Konfig: M5 + M30-Filter):
A) Multi-TF-Konfluenz: Edge wenn nur M30 vs M30+H1 einig
B) Exit nach Qualitaet: MAE/MFE (max Gegen-/Mitlauf in ATR) je Pullback-Bucket
C) Tageszeit-Edge: Edge je Berliner Stunde
D) Vola-Gate: Edge je ATR-Band
"""
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, _N_BARS, _HTF_DEADBAND)
_TF = {"M1": mt5.TIMEFRAME_M1, "M5": mt5.TIMEFRAME_M5, "M15": mt5.TIMEFRAME_M15,
"M30": mt5.TIMEFRAME_M30, "H1": mt5.TIMEFRAME_H1}
_BERLIN = timezone(timedelta(hours=2)) # Juni = CEST (broker-epoch ist UTC+3 → -3+2)
class _NeutralTU:
def snapshot(self): return {"intervals": {}}
def _ema_series(vals, period):
k = 2.0/(period+1); out=[]; e=vals[0]
for i,v in enumerate(vals):
e = v if i==0 else v*k + e*(1-k); out.append(e)
return out
def _atr_series(H,L,C,period=14):
trs=[0.0]
for i in range(1,len(C)):
trs.append(max(H[i]-L[i], abs(H[i]-C[i-1]), abs(L[i]-C[i-1])))
out=[]
for i in range(len(C)):
w=trs[max(1,i-period+1):i+1]; out.append(sum(w)/len(w) if w else None)
return out
def _rep(name, rets):
if not rets: print(f" {name:<20} -"); return
n=len(rets); win=sum(1 for x in rets if x>0)
print(f" {name:<20} n={n:>4} Treffer={100*win/n:>3.0f}% Oe-Edge={sum(rets)/n:+.4f}")
def _sign_at(ts, T, Ef, Es, ATR, dead):
lo,hi,idx=0,len(T)-1,-1
while lo<=hi:
m=(lo+hi)//2
if T[m]<=ts: idx=m; lo=m+1
else: hi=m-1
if idx<_EMA_SLOW or ATR[idx] is None or ATR[idx]<=0: return 0
d=Ef[idx]-Es[idx]
return 0 if abs(d)<dead*ATR[idx] else (1 if d>0 else -1)
def main():
n_bars = int(sys.argv[1]) if len(sys.argv)>1 else 8000
K = 10
if not mt5.initialize(): print("init", mt5.last_error()); sys.exit(1)
sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
sym=sym or "SpotCrude"
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, n_bars+_N_BARS+K+5)
m30b = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M30, 0, n_bars//6+400)
h1b = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_H1, 0, n_bars//12+400)
mt5.shutdown()
if bars is None or m30b is None or h1b is None: print("Bars fehlen"); sys.exit(1)
T=[int(b["time"]) for b in bars]
H=[float(b["high"]) for b in bars]; L=[float(b["low"]) for b in bars]; C=[float(b["close"]) for b in bars]
def prep(bb):
t=[int(x["time"]) for x in bb]; h=[float(x["high"]) for x in bb]
l=[float(x["low"]) for x in bb]; c=[float(x["close"]) for x in bb]
return t, _ema_series(c,_EMA_FAST), _ema_series(c,_EMA_SLOW), _atr_series(h,l,c)
m30 = prep(m30b); h1 = prep(h1b)
w=WaveRecommender(_NeutralTU(), mt5.TIMEFRAME_M5)
confl={"nur M30":[], "M30+H1 einig":[]}
mae={"near<0":[], "0-0.3":[], "0.3-1":[], "1-2":[], "2+":[]}
mfe={k:[] for k in mae}
by_hour={}; atr_vals=[]; rows=[]
for i in range(_N_BARS, len(C)-K):
wc=C[i-_N_BARS:i]; wh=H[i-_N_BARS:i]; wl=L[i-_N_BARS:i]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: continue
ef=_ema_last(wc,_EMA_FAST); es=_ema_last(wc,_EMA_SLOW)
m=_sign_at(T[i], *m30, _HTF_DEADBAND)
rec,_=w._build(ef,es,C[i-1],atr,"M5",5,htf_trend=m)
if rec["signal"]=="WARTEN": continue
d=1 if rec["signal"]=="LONG" else -1
fwd=C[i+K]-C[i]; r=fwd*d
rows.append((r,atr,T[i]))
# A) Konfluenz
h=_sign_at(T[i], *h1, _HTF_DEADBAND)
(confl["M30+H1 einig"] if h==d else confl["nur M30"]).append(r)
# B) MAE/MFE in ATR
seg_h=H[i+1:i+K+1]; seg_l=L[i+1:i+K+1]
if d>0: adv=(min(seg_l)-C[i])/atr; fav=(max(seg_h)-C[i])/atr
else: adv=(C[i]-max(seg_h))/atr; fav=(C[i]-min(seg_l))/atr
near=(C[i-1]-es)/atr*d
b = "near<0" if near<0 else "0-0.3" if near<0.3 else "0.3-1" if near<1 else "1-2" if near<2 else "2+"
mae[b].append(adv); mfe[b].append(fav)
# C) Stunde
hr=datetime.fromtimestamp(T[i]-10800, timezone.utc).astimezone(_BERLIN).hour
by_hour.setdefault(hr,[]).append(r)
atr_vals.append((atr,r))
print("="*60); print(f" {sym} M5+M30 n_bars={len(C)} Vorlauf={K}"); print("="*60)
print("\nA) Multi-TF-Konfluenz:")
_rep("nur M30", confl["nur M30"]); _rep("M30+H1 einig", confl["M30+H1 einig"])
print("\nB) Exit: MAE/MFE (in ATR) je Pullback-Bucket [MAE=Gegenlauf, MFE=Mitlauf]:")
for k in ["near<0","0-0.3","0.3-1","1-2","2+"]:
a=mae[k]; f=mfe[k]
if a: print(f" {k:<8} n={len(a):>4} Oe-MAE={sum(a)/len(a):+.2f} Oe-MFE={sum(f)/len(f):+.2f} MFE/|MAE|={ (sum(f)/len(f))/abs(sum(a)/len(a)+1e-9):.2f}")
print("\nC) Tageszeit-Edge (Berliner Stunde):")
for hr in sorted(by_hour):
_rep(f"{hr:02d}:00", by_hour[hr])
print("\nD) Vola-Gate (ATR-Terzile):")
atr_vals.sort()
n=len(atr_vals); t1=atr_vals[:n//3]; t2=atr_vals[n//3:2*n//3]; t3=atr_vals[2*n//3:]
for nm,seg in [("niedrig ATR",t1),("mittel ATR",t2),("hoch ATR",t3)]:
rr=[r for _,r in seg]; lo=seg[0][0]; hi=seg[-1][0]
_rep(f"{nm} ({lo:.2f}-{hi:.2f})", rr)
if __name__ == "__main__":
main()
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""Inter-Market-Kontext als Verdict-Kandidat (2026-07-19, User-Frage „weitere
Indikatoren?"): die EINZIGE noch ungetestete Indikator-Klasse — Information von
AUSSERHALB des WTI-Charts. Getestet werden zwei Hypothesen:
(A) Brent-Bestätigung: Brent-Trend (EMA12/50 M5) bestätigt das WTI-Signal →
besserer Edge; Brent DAGEGEN → schlechter. (Brent-WTI laufen ~parallel,
Divergenz = möglicher Fehlausbruch.)
(B) DXY-Gegenwind: USDX-Trend gegen die Trade-Richtung (Öl in USD:
Dollar rauf = Öl-Gegenwind für LONG, Dollar runter = Gegenwind für SHORT).
Methodik wie die 8 verworfenen Signal-Filter: ereignisbasierte EMA-Signale auf
WTI M5 (frisches Kreuzen, Totband 0,15×ATR), sequentielle 1-Positions-Sim mit
Live-Exit (SL 2,0×ATR + Trailing 1,5 + BE 1,3), Echtkosten = Bar-Spread/ATR.
Jeder Trade wird nach dem Inter-Market-Zustand AM ENTRY gebucketed. 2 Halbjahre.
Verdict-Regel (vorab festgelegt, gegen Parameter-Fishing): ein Gate/Konfidenz-
Einbau kommt NUR in Frage, wenn der „dagegen"-Bucket in BEIDEN Hälften klar
schlechter ist als „dafür" UND das über beide Feature-Varianten (EMA-Stand und
Momentum N=12/36) robust ist. Maßstab der 6×-Lektion: Signal-Filter tragen kaum.
"""
import sys
import MetaTrader5 as mt5
_MAXH = 288; _ATRMIN = 0.12; _COOL = 6; _DEAD = 0.15
def _ema_series(C, p):
k = 2.0 / (p + 1); e = C[0]; out = [e]
for x in C[1:]:
e = e + k * (x - e); out.append(e)
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 _load(sym, n):
mt5.symbol_select(sym, True)
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, n)
if bars is None or len(bars) < 1000:
print(f"FEHLER: keine Daten für {sym}"); sys.exit(1)
point = mt5.symbol_info(sym).point
T = [int(b["time"]) for b in bars]
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]
return T, H, L, C, SP
def _dir_series(H, L, C):
"""EMA12/50-Richtung mit Totband: +1/-1/0 je Bar."""
e12 = _ema_series(C, 12); e50 = _ema_series(C, 50); A = _atr_series(H, L, C)
out = []
for i in range(len(C)):
a = A[i] or 0
if a <= 0: out.append(0); continue
d = e12[i] - e50[i]
out.append(1 if d > _DEAD*a else (-1 if d < -_DEAD*a else 0))
return out, A
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:<26}"
return (f" {lbl:<26} n={s['n']:>4} WR={s['wr']:>3.0f}% ØR={s['oR']:+.3f} "
f"PF={s['pf']:>4.2f} ΣR={s['sum']:>+6.0f}")
def sim_trades(T, H, L, C, A, SP):
"""Sequentielle Sim: frisches EMA-Signal → Trade mit Live-Exit. Liefert
Liste (entry_index, dir, netto_R)."""
def cost(i, atr): return (SP[i] if SP[i] > 0 else 0.0225)/atr
sig, _ = _dir_series(H, L, C)
out = []; i = 60
while i < len(C) - 2:
if sig[i] == 0 or sig[i] == sig[i-1]:
i += 1; continue
d = sig[i]
atr = max(A[i] or 0, _ATRMIN)
entry = C[i]; eff = entry - d*2.0*atr; hw = entry
end = min(i+_MAXH, len(C)-1); exit_px = C[end]; exit_j = end
for j in range(i+1, end+1):
hj, lj = H[j], L[j]
if (lj <= eff) if d > 0 else (hj >= eff):
exit_px = eff; exit_j = j; break
hw = max(hw, hj) if d > 0 else min(hw, lj)
prof = (C[j]-entry)*d
if prof >= 0.3*atr:
cand = hw - d*1.5*atr
if prof >= 1.3*atr:
cand = max(cand, entry) if d > 0 else min(cand, entry)
eff = max(eff, cand) if d > 0 else min(eff, cand)
out.append((i, d, (exit_px-entry)*d/atr - cost(i, atr)))
i = exit_j + _COOL
return out
def main():
n = int(sys.argv[1]) if len(sys.argv) > 1 else 80000
mt5.initialize()
Tw, Hw, Lw, Cw, SPw = _load("SpotCrude", n+_MAXH+60)
Tb, Hb, Lb, Cb, _ = _load("SpotBrent", n+_MAXH+60)
Tx, Hx, Lx, Cx, _ = _load("USDX", n+_MAXH+60)
mt5.shutdown()
dir_b, Ab = _dir_series(Hb, Lb, Cb)
dir_x, Ax = _dir_series(Hx, Lx, Cx)
idx_b = {t: i for i, t in enumerate(Tb)}
idx_x = {t: i for i, t in enumerate(Tx)}
Aw = _atr_series(Hw, Lw, Cw)
def look(idx, t):
"""Index zum WTI-Zeitstempel (bis 2 Gitterschritte zurück — Feed-Lücken)."""
for dt_ in (0, 300, 600):
j = idx.get(t - dt_)
if j is not None: return j
return None
def mom_dir(C_, A_, j, N, thr=0.3, floor=None):
if j is None or j < N: return None
a = A_[j] or 0
a = max(a, floor) if floor else (a if a > 0 else None)
if not a: return None
m = (C_[j] - C_[j-N]) / a
return 1 if m > thr else (-1 if m < -thr else 0)
trades = sim_trades(Tw, Hw, Lw, Cw, Aw, SPw)
print("="*88)
print(f" Inter-Market-Kontext — WTI M5 ({len(trades)} Trades, seq. Sim, Live-Exit, Echtkosten)")
print(f" Buckets nach Zustand AM ENTRY. Verdict nur bei Robustheit in BEIDEN Hälften.")
print("="*88)
mid_t = Tw[len(Tw)//2]
feats = [] # (half, dir, R, brent_ema, dxy_ema, brent_m12, brent_m36, dxy_m12, dxy_m36)
miss_b = miss_x = 0
for i, d, r in trades:
t = Tw[i]
jb = look(idx_b, t); jx = look(idx_x, t)
if jb is None: miss_b += 1
if jx is None: miss_x += 1
h = 1 if t < mid_t else 2
feats.append((h, d, r,
dir_b[jb] if jb is not None else None,
dir_x[jx] if jx is not None else None,
mom_dir(Cb, Ab, jb, 12, floor=0.12),
mom_dir(Cb, Ab, jb, 36, floor=0.12),
mom_dir(Cx, Ax, jx, 12),
mom_dir(Cx, Ax, jx, 36)))
if miss_b or miss_x:
print(f" (ohne Inter-Market-Match: Brent {miss_b} · USDX {miss_x} — übersprungen)")
def bucket3(val, d):
"""dafür / dagegen / neutral relativ zur Trade-Richtung."""
if val is None: return None
if val == 0: return "neutral"
return "dafür" if val == d else "dagegen"
def bucket_dxy(val, d):
"""DXY: Dollar MIT der Trade-Richtung = Gegenwind (Öl invers zum Dollar)."""
if val is None: return None
if val == 0: return "neutral"
return "Gegenwind" if val == d else "Rückenwind"
sections = [
("(A) Brent-Trend (EMA12/50)", 3, bucket3),
("(A2) Brent-Momentum 1h (N=12)", 5, bucket3),
("(A3) Brent-Momentum 3h (N=36)", 6, bucket3),
("(B) DXY-Trend (EMA12/50)", 4, bucket_dxy),
("(B2) DXY-Momentum 1h (N=12)", 7, bucket_dxy),
("(B3) DXY-Momentum 3h (N=36)", 8, bucket_dxy),
]
order = ("dafür", "dagegen", "neutral", "Rückenwind", "Gegenwind")
for title, col, bfn in sections:
print(f"\n {title}:")
for h in (1, 2):
print(f" H{h}:")
groups = {}
for row in feats:
if row[0] != h: continue
b = bfn(row[col], row[1])
if b is None: continue
groups.setdefault(b, []).append(row[2])
for name in order:
if name in groups:
print(line(name, st(groups[name])))
print("\n Verdict-Regel: Einbau NUR wenn 'dagegen'/'Gegenwind' in BEIDEN Hälften")
print(" klar schlechter als 'dafür'/'Rückenwind' UND über die Varianten robust.")
if __name__ == "__main__":
main()
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Momentum-Continuation als AUTONOMER Setup-Kandidat (2026-07-17): Einstieg in
Richtung eines starken, frischen Momentum-Schubs (Kurs ≥ X×ATR über N Bars gelaufen)
— Trend-Persistenz-Wette, andere Klasse als der Squeeze (Kompression→Ausbruch).
Sequentielle 1-Positions-Sim (wie live), Exit = SL 2,0×ATR + Trailing 1,5 + BE 1,3.
Entry = FRISCHER Schub (mom_N kreuzt X, war die N Bars davor drunter → kein Einstieg
tief in einen ausgelaufenen Move). Kosten = Bar-Spread/ATR. 2 Halbjahre.
Robustheit über N (6/12/24) × X (1,0/1,5/2,0).
Automatisieren nur, wenn eine Kombi ØR & PF in BEIDEN Hälften klar positiv hält
(Maßstab: Squeeze ØR +0,14…+0,23).
"""
import sys
import MetaTrader5 as mt5
_MAXH = 288; _ATRMIN = 0.12; _COOL = 6
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 run(H, L, C, A, SP, lo, hi, N, X):
def cost(i, atr): return (SP[i] if SP[i] > 0 else 0.0225)/atr
Rs = []; i = max(lo, N+1)
while i < hi:
atr = A[i]
if not atr or atr < _ATRMIN or i-N < 0:
i += 1; continue
mom = (C[i]-C[i-N])/atr
momP = (C[i-1]-C[i-1-N])/atr if i-1-N >= 0 else 0.0
# frischer Schub: jetzt |mom|≥X, letzten Bar noch drunter
d = 0
if mom >= X and momP < X: d = 1
elif mom <= -X and momP > -X: d = -1
if d == 0:
i += 1; continue
atr = max(atr, _ATRMIN); entry = C[i]
eff = entry - d*2.0*atr; hw = entry
end = min(i+_MAXH, len(C)-1); exit_px = C[end]; exit_j = end
for j in range(i+1, end+1):
hj, lj = H[j], L[j]
if (lj <= eff) if d > 0 else (hj >= eff): exit_px = eff; exit_j = j; break
hw = max(hw, hj) if d > 0 else min(hw, lj)
prof = (C[j]-entry)*d
if prof >= 0.3*atr:
cand = hw - d*1.5*atr
if prof >= 1.3*atr: cand = max(cand, entry) if d > 0 else min(cand, entry)
eff = max(eff, cand) if d > 0 else min(eff, cand)
Rs.append((exit_px-entry)*d/atr - cost(i, atr))
i = exit_j + _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+_MAXH+30)
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); N_ = len(C); mid = N_//2
print("="*84)
print(f" Momentum-Continuation — {sym} M5 (seq. Sim · Exit SL2/Trail1,5/BE1,3 · Echtkosten)")
print(f" Entry = frischer Momentum-Schub (mom_N kreuzt ±X×ATR). 2 Halbjahre.")
print("="*84)
print(f" {'N/X':>8} | {'H1: n ØR PF':>22} | {'H2: n ØR PF':>22}")
for N in (6, 12, 24):
for X in (1.0, 1.5, 2.0):
s1 = st(run(H, L, C, A, SP, 0, mid, N, X))
s2 = st(run(H, L, C, A, SP, mid, N_, N, X))
def f(s): return f"{s['n']:>4} {s['oR']:+.3f} {s['pf']:>5.2f}" if s else " "
ok = "OK" if (s1 and s2 and s1['oR'] > 0 and s2['oR'] > 0
and s1['pf'] > 1 and s2['pf'] > 1) else ""
print(f" N={N:>2} X={X:.1f} | {f(s1):>22} | {f(s2):>22} {ok}")
print(f"\n Automatisieren nur bei ØR>0 & PF>1 in BEIDEN Hälften (Squeeze-Maßstab +0,14…+0,23).")
if __name__ == "__main__":
main()
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Opening Range Breakout (ORB) als Auto-Setup-Kandidat (2026-07-17, Recherche):
Box = Hoch/Tief der ersten K M5-Bars nach Session-Open; erster Ausbruch ±0,1×ATR
über/unter die Box (Fenster W Bars) wird gehandelt — one-shot je Open. Struktur-
Verwandter des validierten Squeeze (Box→Ausbruch), aber ZEIT-verankert.
Opens in BROKER-Zeit (UTC+3, US-DST-gekoppelt → US-Open konstant 16:30):
EU-Morgen 10:00 (≈ 09:00 Berlin) · US-Open 16:30 (≈ 15:30 Berlin).
Exit = Live-Modell (SL 2,0×ATR + Trailing 1,5 + BE 1,3). Kosten = Bar-Spread/ATR.
2 Halbjahre. Maßstab: Squeeze (ØR +0,14…+0,23 in BEIDEN Hälften).
"""
import sys, datetime as dt
import MetaTrader5 as mt5
_MAXH = 288; _ATRMIN = 0.12; _K_BRK = 0.1; _W = 48 # Breakout-Fenster 4 h
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:<28}"
return (f" {lbl:<28} n={s['n']:>4} WR={s['wr']:>3.0f}% ØR={s['oR']:+.3f} "
f"PF={s['pf']:>4.2f} ΣR={s['sum']:>+6.0f}")
def sim(entry, d, atr, H, L, C, j0):
eff = entry - d*2.0*atr; hw = entry
end = min(j0+_MAXH, len(C)-1); exit_px = C[end]
for j in range(j0, end+1):
hj, lj = H[j], L[j]
if (lj <= eff) if d > 0 else (hj >= eff): return None if False else ((eff-entry)*d/atr, j)
hw = max(hw, hj) if d > 0 else min(hw, lj)
prof = (C[j]-entry)*d
if prof >= 0.3*atr:
cand = hw - d*1.5*atr
if prof >= 1.3*atr: cand = max(cand, entry) if d > 0 else min(cand, entry)
eff = max(eff, cand) if d > 0 else min(eff, cand)
return ((exit_px-entry)*d/atr, end)
def run(T, H, L, C, A, SP, lo, hi, open_h, open_m, K):
def cost(i, atr): return (SP[i] if SP[i] > 0 else 0.0225)/atr
Rs = []
i = lo
while i < hi:
t = dt.datetime.utcfromtimestamp(T[i])
if not (t.hour == open_h and t.minute == open_m):
i += 1; continue
if i+K+2 >= hi:
break
atr = max(A[i+K] or 0, _ATRMIN) # Floor wie live (Skip wäre Selektions-
# Artefakt: H1 hat 81 % Bars unter 0,12 → sonst fällt der EU-Morgen weg)
boxHi = max(H[i:i+K]); boxLo = min(L[i:i+K])
up = boxHi + _K_BRK*atr; dn = boxLo - _K_BRK*atr
hit = None
for j in range(i+K, min(i+K+_W, hi)):
if H[j] >= up: hit = (j, 1, up); break
if L[j] <= dn: hit = (j, -1, dn); break
if hit is None:
i += K; continue
j, d, lvl = hit
r, xj = sim(lvl, d, atr, H, L, C, j+1)
Rs.append(r - cost(j, atr))
i = xj + 1 # one-shot je Open, weiter nach Exit
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+_MAXH+30)
point = mt5.symbol_info(sym).point; mt5.shutdown()
T = [int(b["time"]) for b in bars]
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); N = len(C); mid = N//2
print("="*88)
print(f" Opening Range Breakout — {sym} M5 (one-shot je Open · Exit live · Echtkosten)")
print(f" Box = erste K Bars nach Open (Brokerzeit) · Ausbruch ±{_K_BRK}×ATR · Fenster 4 h")
print("="*88)
for lbl, lo, hi in (("H1 (alt)", 0, mid), ("H2 (neu)", mid, N-_MAXH-1)):
print(f"\n{lbl}:")
for name, oh, om in (("EU-Open 10:00 Brk", 10, 0), ("US-Open 16:30 Brk", 16, 30)):
for K in (3, 6):
s = st(run(T, H, L, C, A, SP, lo, hi, oh, om, K))
print(line(f"{name} Box={K*5}min", s))
print(f"\n Maßstab: Squeeze ØR +0,14…+0,23 & PF>1 in BEIDEN Hälften.")
if __name__ == "__main__":
main()
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Misst, ob ein Pullback-Einstieg (Kurs nahe der EMA = 'Wellenspitze' der
Gegenbewegung) den Edge gegenueber dem aktuellen Dauer-Trendsignal hebt.
Live-Konfig: Basis M5 + M30-Gegen-Trend-Filter (wie eingebaut). Fuer jedes
Signal wird der Abstand zur EMA50 in ATR ('near') berechnet und der Edge je
Naehe-Bucket gemessen. near klein = frisch am EMA (Pullback beendet),
near gross = weit weg gechased.
"""
from __future__ import annotations
import sys
import MetaTrader5 as mt5
from core.wave_rec import (WaveRecommender, _atr, _ema_last,
_EMA_FAST, _EMA_SLOW, _N_BARS, _HTF_DEADBAND)
_TF = {"M1": mt5.TIMEFRAME_M1, "M5": mt5.TIMEFRAME_M5, "M15": mt5.TIMEFRAME_M15,
"M30": mt5.TIMEFRAME_M30, "H1": mt5.TIMEFRAME_H1}
class _NeutralTU:
def snapshot(self):
return {"intervals": {}}
def _ema_series(vals, period):
k = 2.0 / (period + 1); out = []; e = vals[0]
for i, v in enumerate(vals):
e = v if i == 0 else v * k + e * (1.0 - k)
out.append(e)
return out
def _rep(name, rets):
if not rets:
print(f" {name:<22} keine Signale"); return
n = len(rets); win = sum(1 for x in rets if x > 0)
print(f" {name:<22} n={n:>4} Treffer={100*win/n:>3.0f}% "
f"Oe-Edge={sum(rets)/n:+.4f} Summe={sum(rets):+.2f}")
def main():
base_lbl = (sys.argv[1].upper() if len(sys.argv) > 1 else "M5")
n_bars = int(sys.argv[2]) if len(sys.argv) > 2 else 4000
K = int(sys.argv[3]) if len(sys.argv) > 3 else 10
tf, htf = _TF[base_lbl], mt5.TIMEFRAME_M30
if not mt5.initialize():
print("MT5-Init:", mt5.last_error()); sys.exit(1)
sym = None
for cand in ("SpotCrude", "USOIL", "WTI", "XTIUSD"):
if mt5.symbol_info(cand):
sym = cand; break
sym = sym or "SpotCrude"
bars = mt5.copy_rates_from_pos(sym, tf, 0, n_bars + _N_BARS + K + 5)
hbars = mt5.copy_rates_from_pos(sym, htf, 0, n_bars // 2 + 300)
mt5.shutdown()
if bars is None or hbars is None:
print("Zu wenige Bars."); sys.exit(1)
T = [int(b["time"]) for b in bars]
H = [float(b["high"]) for b in bars]; L = [float(b["low"]) for b in bars]
C = [float(b["close"]) for b in bars]
hT = [int(b["time"]) for b in hbars]
hC = [float(b["close"]) for b in hbars]
hH = [float(b["high"]) for b in hbars]; hL = [float(b["low"]) for b in hbars]
hEf = _ema_series(hC, _EMA_FAST); hEs = _ema_series(hC, _EMA_SLOW)
def htf_sign(ts):
lo, hi, idx = 0, len(hT) - 1, -1
while lo <= hi:
mid = (lo + hi) // 2
if hT[mid] <= ts: idx = mid; lo = mid + 1
else: hi = mid - 1
if idx < _EMA_SLOW: return 0
atr = _atr(hH[:idx+1], hL[:idx+1], hC[:idx+1])
if not atr: return 0
d = hEf[idx] - hEs[idx]
return 0 if abs(d) < _HTF_DEADBAND * atr else (1 if d > 0 else -1)
w = WaveRecommender(_NeutralTU(), tf)
buckets = {"(-inf,0)": [], "[0,0.3)": [], "[0.3,1)": [], "[1,2)": [], "[2,inf)": []}
gate03, nogate = [], []
for i in range(_N_BARS, len(C) - K):
win_c = C[i-_N_BARS:i]; win_h = H[i-_N_BARS:i]; win_l = L[i-_N_BARS:i]
atr = _atr(win_h, win_l, win_c)
if not atr or atr <= 0: continue
ef = _ema_last(win_c, _EMA_FAST); es = _ema_last(win_c, _EMA_SLOW)
ht = htf_sign(T[i])
rec, _ = w._build(ef, es, C[i-1], atr, base_lbl, 5, htf_trend=ht)
sig = rec["signal"]
if sig == "WARTEN": continue
fwd = C[i+K] - C[i]
r = fwd if sig == "LONG" else -fwd
stretch = (C[i-1] - es) / atr
near = stretch if sig == "LONG" else -stretch
nogate.append(r)
if near < 0: buckets["(-inf,0)"].append(r)
elif near < 0.3: buckets["[0,0.3)"].append(r)
elif near < 1.0: buckets["[0.3,1)"].append(r)
elif near < 2.0: buckets["[1,2)"].append(r)
else: buckets["[2,inf)"].append(r)
if near <= 0.3: gate03.append(r)
print("=" * 70)
print(f" Pullback-Test — {sym} Basis={base_lbl}+M30-Filter Vorlauf={K}")
print("=" * 70)
print("Edge je Abstand zur EMA (near = wie nah am EMA in ATR; klein = frisch):")
for k in ["(-inf,0)", "[0,0.3)", "[0.3,1)", "[1,2)", "[2,inf)"]:
_rep("near "+k, buckets[k])
print("\nVergleich Gate:")
_rep("OHNE Gate (alle)", nogate)
_rep("MIT Gate near<=0.3", gate03)
if __name__ == "__main__":
main()
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Range-Strategie messen (Track B, Echtkosten, 2 Hälften): Einstieg AM Level statt
mit dem Trend, Ausstieg am gegenüberliegenden Level — und braucht es dann noch Trailing?
Einstieg LONG: Kurs an Support S, P(Support bricht ABWÄRTS) < X (Level hält) → LONG.
Einstieg SHORT: Kurs an Resistance R, P(R bricht AUFWÄRTS) < X → SHORT.
P(break) aus dem kalibrierten Modell (engine._p_break), Merkmale in Bruchrichtung.
Ziel = gegenüberliegendes Level. SL = Level ∓ sl×ATR.
Exit-Varianten je Trade:
FIX nur festes Ziel (Level-zu-Level), Stop fix
FIX+TR Ziel + Trailing (nach Fortschritt Stop nachziehen)
TRAIL nur Trailing, KEIN festes Ziel (= brauchen wir das?)
Vergleich der ΣR (in ATR, Echtkosten) je Hälfte. Zeigt, ob Einstieg-am-Level trägt
UND ob Trailing dort noch etwas bringt.
"""
import sys, math
import MetaTrader5 as mt5
from core.wave_rec import (_EMA_FAST, _EMA_SLOW, _N_BARS)
from core.engine import _p_break
_ATRMIN=0.12; _PIV_K=3; _LOOKBACK=300; _MAXH=200
_SL_ATR=1.5; _TRAILON=0.3; _MULT=1.5; _BE=1.0
_PB_X=0.60 # Level gilt als "hält", wenn P(break) < X
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 sim(entry,d,atr,H,L,C,j0, target=None, trail=False):
sl=entry-d*_SL_ATR*atr; hw=entry
end=min(j0+_MAXH,len(C)-1)
for j in range(j0,end+1):
hi,lo=H[j],L[j]
if (lo<=sl) if d>0 else (hi>=sl): return (sl-entry)*d/atr
if target is not None and ((hi>=target) if d>0 else (lo<=target)):
return (target-entry)*d/atr
if trail:
hw=max(hw,hi) if d>0 else min(hw,lo)
profit=(hw-entry)*d
if profit>=_TRAILON*atr:
cand=hw-d*_MULT*atr
if profit>=_BE*atr: cand=max(cand,entry) if d>0 else min(cand,entry)
sl=max(sl,cand) if d>0 else min(sl,cand)
return (C[end]-entry)*d/atr
def levels(H,L,i):
phis,plos=[],[]
for j in range(i-_LOOKBACK+_PIV_K, i-_PIV_K):
if H[j]==max(H[j-_PIV_K:j+_PIV_K+1]): phis.append(H[j])
if L[j]==min(L[j-_PIV_K:j+_PIV_K+1]): plos.append(L[j])
return phis,plos
def collect(a,b,H,L,C,EF,ES,AT,SP,point):
out={"FIX":[],"FIX+TR":[],"TRAIL":[]}; n_ent=0
for i in range(max(a,_N_BARS,_LOOKBACK), min(b,len(C)-_MAXH-1)):
atr=AT[i]
if not atr or atr<=0: continue
atr=max(atr,_ATRMIN); cur=C[i]; cost=(SP[i] if SP[i]>0 else 0.0225)/atr
phis,plos=levels(H,L,i)
sup=max([p for p in plos if p<cur],default=None)
res=min([p for p in phis if p>cur],default=None)
# LONG an Support
for d,lvl,tgt in ((1,sup,res),(-1,res,sup)):
if lvl is None or tgt is None: continue
if abs(cur-lvl) > 0.15*atr: continue # nicht am Level
# P(Level bricht in Bruchrichtung bd) — bd = -d (Support bricht abwärts)
bd=-d
mom6=(C[i]-C[max(0,i-6)])/atr*bd
mom3=(C[i]-C[max(0,i-3)])/atr*bd
wt=1.0 if (EF[i]-ES[i])*bd>0 else 0.0
dist=abs(tgt-lvl)/atr
if _p_break(mom6,mom3,wt,dist) >= _PB_X: continue # Level bricht wahrscheinlich → kein Einstieg
n_ent+=1
out["FIX"].append(sim(cur,d,atr,H,L,C,i+1,target=tgt,trail=False)-cost)
out["FIX+TR"].append(sim(cur,d,atr,H,L,C,i+1,target=tgt,trail=True)-cost)
out["TRAIL"].append(sim(cur,d,atr,H,L,C,i+1,target=None,trail=True)-cost)
return out,n_ent
def st(v):
if not v: return "n=0"
n=len(v); w=sum(1 for x in v if x>0)
g=sum(x for x in v if x>0); ls=-sum(x for x in v if x<0)
return (f"n={n:>5} WR={100*w/n:>3.0f}% Ø-R={sum(v)/n:+.3f} "
f"PF={(g/ls if ls>0 else 99):>4.2f} ΣR={sum(v):+.0f}")
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 80000
mt5.initialize(); sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
si=mt5.symbol_info(sym); point=si.point
bars=None
for req in (n,80000,60000,40000):
bars=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,req)
if bars is not None and len(bars)>2000: break
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]
EF=_ema_series(C,_EMA_FAST); ES=_ema_series(C,_EMA_SLOW); AT=_atr_series(H,L,C)
mid=len(C)//2
print("="*88)
print(f" RANGE-Strategie (Einstieg AM Level, P(break)<{_PB_X}) — {sym} M5 Echtkosten, SL {_SL_ATR}×ATR")
print("="*88)
for lbl,a,b in (("H1 (alt)",_N_BARS,mid),("H2 (neu)",mid,len(C))):
d,ne=collect(a,b,H,L,C,EF,ES,AT,SP,point)
print(f"\n{lbl}: {ne} Einstiege am Level")
print(f" FIX (nur Level-Ziel) {st(d['FIX'])}")
print(f" FIX+TR (Ziel + Trailing) {st(d['FIX+TR'])}")
print(f" TRAIL (nur Trailing) {st(d['TRAIL'])}")
print("\n Trägt Einstieg-am-Level, wenn FIX/FIX+TR in BEIDEN Hälften positiv sind.")
print(" Trailing nötig? → nur wenn FIX+TR bzw. TRAIL klar über FIX liegt.")
if __name__=="__main__":
main()
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Schlüssel-Messungen mit ECHTEN Kosten nachgerechnet (Track B):
Kosten je Trade = Bar-`spread`(Signal-Bar) / ATR(Signal-Bar) in R (Roundtrip = 1×Spread;
Kommission laut DB ~0). Bisher pauschal 0,1×ATR — real Ø 0,265, nachts bis 0,5.
(1) Netto-R je Berlin-Stunde (beide Hälften) — welche Stunden tragen echt?
(2) Gate-Politiken: kein Gate vs. Nacht-Blackout 07 vs. 07+12 — beide Hälften.
(3) breakout_k 0,3/0,5/1,0 mit echten Kosten (Kosten am Bestätigungs-Bar).
"""
import sys, datetime as dt
from zoneinfo import ZoneInfo
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (_EMA_FAST, _EMA_SLOW, _N_BARS, _ANGLE_LR, _ANGLE_DEAD,
_REVERSAL_STRETCH, _STRETCH_MAX)
_MAXH=200; _TRAILON=0.3; _TPTRAIL=0.5; _ATRMIN=0.12; _SL_ATR=2.0; _CONFIRM_W=12
_BROKER_OFF=3*3600; _BERLIN=ZoneInfo("Europe/Berlin")
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 simulate(entry,d,atr,sl,H,L,C,j0):
eff=sl; hw=entry; trail=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]
if (lo<=eff) if d>0 else (hi>=eff): return (eff-entry)*d/atr
hw=max(hw,hi) if d>0 else min(hw,lo)
if (C[j]-entry)*d>=_TRAILON*atr: trail=True
if trail:
lock=hw-d*_TPTRAIL*atr
eff=max(eff,lock) if d>0 else min(eff,lock)
return (exit_px-entry)*d/atr
def bhour(raw):
return dt.datetime.fromtimestamp(int(raw)-_BROKER_OFF,tz=dt.timezone.utc).astimezone(_BERLIN).hour
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 80000
mt5.initialize(); sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
si=mt5.symbol_info(sym); point=si.point
bars=None
for req in (n,80000,60000,40000):
bars=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,req)
if bars is not None and len(bars)>2000: break
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]; T=[int(b["time"]) for b in bars]
SP=[float(b["spread"])*point for b in bars] # echter Spread je Bar
EF=_ema_series(C,_EMA_FAST); ES=_ema_series(C,_EMA_SLOW); AT=_atr_series(H,L,C)
mid=len(C)//2; TH=_REVERSAL_STRETCH
def cost(i,atr): return (SP[i] if SP[i]>0 else 0.0225)/atr # in R
def collect(a,b):
out=[]
for i in range(max(a,_N_BARS), min(b,len(C)-_MAXH-1)):
atr=AT[i]
if not atr or atr<=0: continue
atr=max(atr,_ATRMIN); es=ES[i]; ef=EF[i]
stretch=(C[i]-es)/atr
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR); ad=ang-90.0
d=0
if stretch<=-TH and ad>=_ANGLE_DEAD: d=1
elif stretch>=TH and ad<=-_ANGLE_DEAD: d=-1
elif abs(stretch)<_STRETCH_MAX: d=1 if ef>es else -1 if ef<es else 0
if not d: continue
R=simulate(C[i],d,atr,C[i]-d*_SL_ATR*atr,H,L,C,i+1)
out.append((i,d,atr,bhour(T[i]),R-cost(i,atr)))
return out
halves=[("H1 (alt)",_N_BARS,mid),("H2 (neu)",mid,len(C))]
EV={lbl:collect(a,b) for lbl,a,b in halves}
print("="*92)
print(f" ECHTE Kosten (Spread je Signal-Bar) — {sym} M5 ({len(C)} Bars)")
print("="*92)
# (1) Netto je Stunde, beide Hälften nebeneinander
print("\n(1) Netto-R je Berlin-Stunde (echte Kosten):")
print(f" {'Std':>3} {'H1 netto':>10} {'H2 netto':>10} beide negativ?")
night_neg=[]
for h in range(24):
cells=[]; neg=True
for lbl,_,_ in halves:
v=[x[4] for x in EV[lbl] if x[3]==h]
m=sum(v)/len(v) if v else 0.0
cells.append(f"{m:+.3f}(n{len(v)})")
if m>=0: neg=False
if neg: night_neg.append(h)
print(f" {h:>3} {cells[0]:>12} {cells[1]:>12} {'← NEG in beiden' if neg else ''}")
print(f" → in BEIDEN Hälften netto negativ: {night_neg}")
# (2) Gate-Politiken
print("\n(2) Gesamt-Netto-R je Politik:")
POL=[("Kein Gate",set()),("Nacht 07",set(range(0,8))),
("Nacht 07 + 12",set(range(0,8))|{12}),
("nur robust-negative",set(night_neg))]
for name,blk in POL:
cells=[]
for lbl,_,_ in halves:
kept=[x[4] for x in EV[lbl] if x[3] not in blk]
cells.append(f"ΣR{sum(kept):+8.0f} (n{len(kept)})")
print(f" {name:<22} {cells[0]:>24} {cells[1]:>24}")
# (3) breakout_k mit echten Kosten
print("\n(3) breakout_k mit echten Kosten (Kosten am Bestätigungs-Bar):")
for lbl,a,b in halves:
sigs=[(i,d,atr) for (i,d,atr,_,_) in EV[lbl]]
line=f" {lbl}: "
for k in (0.3,0.5,1.0):
Rs=[]
for (i,d,atr) in sigs:
e0=C[i]; level=e0+d*k*atr; invalid=e0-d*k*atr; je=None
for j in range(i+1, min(i+1+_CONFIRM_W, len(C)-_MAXH-1)):
if d>0:
if L[j]<=invalid: break
if H[j]>=level: je=j; break
else:
if H[j]>=invalid: break
if L[j]<=level: je=j; break
if je is None: continue
Rs.append(simulate(level,d,atr,level-d*_SL_ATR*atr,H,L,C,je+1)-cost(je,atr))
line+=f"k={k}: ΣR{sum(Rs):+7.0f}(n{len(Rs)}{sum(Rs)/max(1,len(Rs)):+.3f}) "
print(line)
if __name__=="__main__":
main()
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Reversal-Setups mit ECHTER Exit-Simulation (SL 2,0×ATR, Breakeven 1,3,
Trailing HW∓1,5×ATR) — zeigt, ob der antizyklische Einstieg den Gegenlauf vor
der Wende überlebt oder vorher ausgestoppt wird. Vergleich zum normalen Signal.
"""
from __future__ import annotations
import sys
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (WaveRecommender, _atr, _ema_last, _EMA_FAST, _EMA_SLOW,
_N_BARS, _STRETCH_MAX, _HTF_DEADBAND, _ANGLE_DEAD)
from backtest_exit import simulate, _ATR_MIN, _MAXH, _ema_series, _atr_series
_LR = 14
class _NeutralTU:
def snapshot(self): return {"intervals": {}}
def _metrics(name, Rs):
if not Rs: print(f" {name:<24} -"); return
n=len(Rs); win=sum(1 for r in Rs if r>0)
g=sum(r for r in Rs if r>0); l=-sum(r for r in Rs if r<0)
pf=g/l if l>0 else 9.99
print(f" {name:<24} n={n:>4} Treffer={100*win/n:>3.0f}% Oe-R={sum(Rs)/n:+.3f} "
f"Summe={sum(Rs):+.1f} PF={pf:.2f} Worst={min(Rs):+.2f}")
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 12000
if not mt5.initialize(): print("init",mt5.last_error()); sys.exit(1)
sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
sym=sym or "SpotCrude"
bars=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,n+_N_BARS+_MAXH+5)
m30b=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M30,0,n//6+400)
mt5.shutdown()
if bars is None: print("Bars fehlen"); sys.exit(1)
T=[int(b["time"]) for b in bars]; H=[float(b["high"]) for b in bars]
L=[float(b["low"]) for b in bars]; C=[float(b["close"]) for b in bars]
mT=[int(b["time"]) for b in m30b]; mc=[float(b["close"]) for b in m30b]
mh=[float(b["high"]) for b in m30b]; ml=[float(b["low"]) for b in m30b]
mEf=_ema_series(mc,_EMA_FAST); mEs=_ema_series(mc,_EMA_SLOW); mATR=_atr_series(mh,ml,mc)
def m30s(ts):
lo,hi,idx=0,len(mT)-1,-1
while lo<=hi:
md=(lo+hi)//2
if mT[md]<=ts: idx=md; lo=md+1
else: hi=md-1
if idx<_EMA_SLOW or mATR[idx] is None or mATR[idx]<=0: return 0
dd=mEf[idx]-mEs[idx]
return 0 if abs(dd)<_HTF_DEADBAND*mATR[idx] else (1 if dd>0 else -1)
w=WaveRecommender(_NeutralTU(),mt5.TIMEFRAME_M5)
rev=[]; rev_long=[]; rev_short=[]; trend=[]
for i in range(_N_BARS, len(C)-_MAXH-1):
wc=C[i-_N_BARS:i]; wh=H[i-_N_BARS:i]; wl=L[i-_N_BARS:i]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: continue
ef=_ema_last(wc,_EMA_FAST); es=_ema_last(wc,_EMA_SLOW)
stretch=(C[i-1]-es)/atr; ang=calc_trend_angle(C[i-_LR-2:i], _LR); ad=ang-90.0
a=max(atr,_ATR_MIN)
# Reversal?
d=0
if stretch<=-_STRETCH_MAX and ad>=_ANGLE_DEAD: d=1
elif stretch>=_STRETCH_MAX and ad<=-_ANGLE_DEAD: d=-1
if d!=0:
R,_=simulate(C[i], d, a, 2.0, H, L, C, i+1, be=1.3)
rev.append(R); (rev_long if d>0 else rev_short).append(R)
continue
# normales Trendsignal (mit M30-Filter) zum Vergleich
rec,_=w._build(ef,es,C[i-1],atr,"M5",5,htf_trend=m30s(T[i]))
if rec["signal"]!="WARTEN":
dd=1 if rec["signal"]=="LONG" else -1
R,_=simulate(C[i], dd, a, 2.0, H, L, C, i+1, be=1.3)
trend.append(R)
print("="*72)
print(f" Reversal mit ECHTER Exit-Simulation — {sym} M5 (SL 2,0 · BE 1,3 · Trail)")
print("="*72)
_metrics("Reversal gesamt", rev)
_metrics(" Reversal LONG", rev_long)
_metrics(" Reversal SHORT", rev_short)
_metrics("Normales Trendsignal", trend)
print("\n Oe-R = Ø/Trade in ATR · Worst = größter Einzelverlust (Gegenlauf/SL)")
if __name__=="__main__":
main()
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Bringt es Edge, den REVERSAL-Einstieg (antizyklischer Bounce: überdehnt + Winkel
gedreht) zu VERWERFEN, wenn der H1-Trend klar DAGEGEN steht?
Teilt alle Reversal-Signale nach H1-Ausrichtung (mit/gegen/flach zur Signalrichtung)
und misst je Bucket die echte Exit-R (SL 2×ATR + Trailing-TP). Wenn 'gegen' klar
schlechter/negativ ist → Veto lohnt.
"""
import sys, bisect
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (_atr, _ema_last, _EMA_FAST, _EMA_SLOW, _N_BARS,
_ANGLE_LR, _ANGLE_DEAD, _HTF_DEADBAND)
_MAXH=240; _TRAILON=0.3; _TPTRAIL=0.5; _ATRMIN=0.12; _SL_ATR=2.0
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 simulate(entry,d,atr,sl,H,L,C,j0):
eff=sl; hw=entry; trail=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]
if (lo<=eff) if d>0 else (hi>=eff): return (eff-entry)*d/atr
hw=max(hw,hi) if d>0 else min(hw,lo)
if (C[j]-entry)*d>=_TRAILON*atr: trail=True
if trail:
lock=hw-d*_TPTRAIL*atr
eff=max(eff,lock) if d>0 else min(eff,lock)
return (exit_px-entry)*d/atr
def stats(Rs):
if not Rs: return " -"
n=len(Rs); w=sum(1 for r in Rs if r>0)
g=sum(r for r in Rs if r>0); ls=-sum(r for r in Rs if r<0)
pf=g/ls if ls>0 else 99.9
return f"n={n:>4} Treffer={100*w/n:>3.0f}% Ø-R={sum(Rs)/n:+.3f} PF={pf:>4.2f} Worst={min(Rs):+.2f} ΣR={sum(Rs):+.0f}"
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 40000
mt5.initialize()
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+_N_BARS+_MAXH+5)
h1=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_H1,0,20000)
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]
T=[int(b["time"]) for b in bars]
ES=_ema_series(C,_EMA_SLOW); AT=_atr_series(H,L,C)
# H1-Regime
hT=[int(b["time"]) for b in h1]; hc=[float(b["close"]) for b in h1]
hh=[float(b["high"]) for b in h1]; hl=[float(b["low"]) for b in h1]
hEf=_ema_series(hc,_EMA_FAST); hEs=_ema_series(hc,_EMA_SLOW); hA=_atr_series(hh,hl,hc)
def h1_sign(ts):
idx=bisect.bisect_right(hT,ts)-1
if idx<_EMA_SLOW or hA[idx] is None or hA[idx]<=0: return 0
dd=hEf[idx]-hEs[idx]
return 0 if abs(dd)<_HTF_DEADBAND*hA[idx] else (1 if dd>0 else -1)
print("="*92)
print(f" Reversal-Einstieg nach H1-Ausrichtung — {sym} M5 Exit: SL {_SL_ATR}×ATR + Trailing-TP")
print(" Frage: sind 'gegen H1' laufende Reversals schlechter (→ Veto lohnt)?")
print("="*92)
for TH in (3.0, 3.5):
B={"mit":[], "gegen":[], "flach":[], "alle":[]}
for i in range(_N_BARS, len(C)-_MAXH-1):
atr=AT[i]
if not atr or atr<=0: continue
atr=max(atr,_ATRMIN); es=ES[i]
stretch=(C[i]-es)/atr
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR); ad=ang-90.0
for d,cs,ca in ((1, stretch<=-TH, ad>=_ANGLE_DEAD),
(-1, stretch>=TH, ad<=-_ANGLE_DEAD)):
if not (cs and ca): continue # echtes Reversal-Signal (überdehnt+Winkel)
R=simulate(C[i],d,atr,C[i]-d*_SL_ATR*atr,H,L,C,i+1)
reg=h1_sign(T[i])
cls="mit" if reg==d else "gegen" if reg==-d else "flach"
B[cls].append(R); B["alle"].append(R)
print(f"\nReversal-Schwelle TH={TH}×ATR:")
for cls in ("alle","mit","gegen","flach"):
print(f" {cls:<6} {stats(B[cls])}")
veto=[r for c in ("mit","flach") for r in B[c]]
print(f" → mit Veto (gegen verworfen): {stats(veto)} [ohne Veto = 'alle']")
print("\n 'gegen' = H1-Trend GEGEN das Reversal (Gegen-Trend-Fade) — Kandidat fürs Veto")
if __name__=="__main__":
main()
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Reversal-Konfidenz-Boni (offener Befund Review 2026-07-15):
Im Reversal-Zweig vergeben „starker Trend" (+10/+5, |sep|-abhängig) und
„⭐ tiefer Pullback" (+15, bei REV per Definition IMMER) Konfidenz-Boni, obwohl
beide beim Reversal GEGEN das Signal stehen. Effekt: Reversals passieren das
_MIN_CONF=55-Gate leichter.
Messung: Signale aus der echten `_build`-Logik (M30-Filter + H1-Konfluenz +
Winkel, hour=None = gate-frei wie alle Backtests). Für REV-Signale wird eine
korrigierte Konfidenz conf_fix = conf (15 + Trendstärke-Bonus) gerechnet.
Vergleich Gate LIVE (conf≥55) vs. Gate FIX (conf_fix≥55) — entscheidend ist der
Edge der Trades, die durch den Fix NEU RAUSFALLEN: nur wenn die in BEIDEN
Hälften negativ sind, ist der Fix berechtigt. Exit = live (SL2,0/Trail1,5/BE1,3),
Kosten = Bar-Spread/ATR. R = Profit/ATR.
"""
import sys, bisect
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (WaveRecommender, _atr, _ema_last, _ema_series,
_EMA_FAST, _EMA_SLOW, _N_BARS, _HTF_DEADBAND, _ANGLE_LR)
_MAXH = 288; _ATRMIN = 0.12; _GATE = 55
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 sim(entry, d, atr, H, L, C, j0, sl_atr=2.0, trail=1.5, trail_on=0.3, be_on=1.3):
eff = entry - d*sl_atr*atr; hw = entry
end = min(j0+_MAXH, len(C)-1); exit_px = C[end]
for j in range(j0, end+1):
hi, lo = H[j], L[j]
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
if prof >= trail_on*atr:
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)
return (exit_px-entry)*d/atr
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 99.9), sum=s)
def line(lbl, s):
if not s: return f" {lbl:<30}"
return (f" {lbl:<30} n={s['n']:>4} WR={s['wr']:>3.0f}% ØR={s['oR']:+.3f} "
f"PF={s['pf']:>4.2f} ΣR={s['sum']:>+6.0f}")
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+_N_BARS+_MAXH+5)
m30 = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M30, 0, n//6+500)
h1 = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_H1, 0, n//12+500)
si = mt5.symbol_info(sym); point = si.point
mt5.shutdown()
T = [int(b["time"]) for b in bars]; 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]
def series(rr):
t = [int(b["time"]) for b in rr]; c = [float(b["close"]) for b in rr]
hh = [float(b["high"]) for b in rr]; ll = [float(b["low"]) for b in rr]
return t, _ema_series(c, _EMA_FAST), _ema_series(c, _EMA_SLOW), _atr_series(hh, ll, c)
mT, mEf, mEs, mA = series(m30)
hT, hEf, hEs, hA = series(h1)
def tf_sign(tt, ef, es, aa, ts):
i = bisect.bisect_right(tt, ts)-1
if i < _EMA_SLOW or aa[i] is None or aa[i] <= 0: return 0
dd = ef[i]-es[i]
return 0 if abs(dd) < _HTF_DEADBAND*aa[i] else (1 if dd > 0 else -1)
w = WaveRecommender(type("T", (), {"snapshot": lambda s: {"intervals": {}}})(), mt5.TIMEFRAME_M5)
def cost(i, atr): return (SP[i] if SP[i] > 0 else 0.0225)/atr
sigs = [] # (i, d, atr, conf, conf_fix, is_rev)
for i in range(_N_BARS, len(C)-_MAXH-1):
wc = C[i-_N_BARS:i]; wh = H[i-_N_BARS:i]; wl = L[i-_N_BARS:i]
atr = _atr(wh, wl, wc)
if not atr or atr <= 0: continue
ef = _ema_last(wc, _EMA_FAST); es = _ema_last(wc, _EMA_SLOW)
a5 = calc_trend_angle(C[i-_ANGLE_LR-2:i], _ANGLE_LR)
rec, _ = w._build(ef, es, C[i-1], atr, "M5", 0,
htf_trend=tf_sign(mT, mEf, mEs, mA, T[i]),
h1_trend=tf_sign(hT, hEf, hEs, hA, T[i]), angle=a5)
if rec["signal"] == "WARTEN": continue
d = 1 if rec["signal"] == "LONG" else -1
conf = int(rec.get("conf_pct") or 0)
is_rev = "REV" in (rec.get("setup") or "")
conf_fix = conf
if is_rev:
sep = (ef - es) / atr
bonus = 15 + (10 if abs(sep) >= 0.5 else 5 if abs(sep) >= 0.25 else 0)
conf_fix = conf - bonus
sigs.append((i, d, max(atr, _ATRMIN), conf, conf_fix, is_rev))
mid = len(C)//2
print("="*86)
print(f" Reversal-Konfidenz-Boni — {sym} M5 ({len(sigs)} Signale · Gate {_GATE} · Echtkosten)")
print(f" FIX = beim Reversal ohne '+15 Pullback' und '+10/5 Trendstärke'")
print("="*86)
for lbl, lo, hi in (("H1 (alt)", 0, mid), ("H2 (neu)", mid, len(C))):
seg = [x for x in sigs if lo <= x[0] < hi]
def R(x): return sim(C[x[0]], x[1], x[2], H, L, C, x[0]+1) - cost(x[0], x[2])
rev_all = [x for x in seg if x[5]]
rev_live = [x for x in rev_all if x[3] >= _GATE]
rev_fix = [x for x in rev_all if x[4] >= _GATE]
dropped = [x for x in rev_all if x[3] >= _GATE and x[4] < _GATE]
print(f"\n{lbl}: ({len(seg)} Signale, davon {len(rev_all)} Reversal)")
print(line("REV alle (ohne Gate)", st([R(x) for x in rev_all])))
print(line("REV durch Gate LIVE (jetzt)", st([R(x) for x in rev_live])))
print(line("REV durch Gate FIX", st([R(x) for x in rev_fix])))
sd = st([R(x) for x in dropped])
print(line("→ FÄLLT NEU RAUS (Fix-Opfer)", sd) +
(" <- Edge<0 = Fix berechtigt" if sd and sd["oR"] < 0 else ""))
print(f"\n Fix NUR umsetzen, wenn die 'fällt neu raus'-Gruppe in BEIDEN Hälften ØR<0 hat")
print(f" (sonst würden profitable Reversals unters Gate gedrückt — 7× gelernte Lektion).")
if __name__ == "__main__":
main()
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Misst einen Rebound/Reversal-Einstieg für ÜBERDEHNTE Lagen (jetzt = WARTEN):
Kurs weit von der EMA weg (|stretch| >= _STRETCH_MAX) UND Regressions-Winkel
hat in die Gegenrichtung gedreht → antizyklischer Einstieg in Winkel-Richtung.
überdehnt UNTER EMA + Winkel auf → LONG
überdehnt ÜBER EMA + Winkel ab → SHORT
Kontrolle: gleiche Überdehnung OHNE Winkel-Bestätigung (reines Fade).
"""
from __future__ import annotations
import sys
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (_atr, _ema_last, _EMA_FAST, _EMA_SLOW, _N_BARS,
_STRETCH_MAX)
_LR = 14
def _rep(name, r):
if not r: print(f" {name:<28} -"); return
n=len(r); w=sum(1 for x in r if x>0)
print(f" {name:<28} n={n:>4} Treffer={100*w/n:>3.0f}% Oe-Edge={sum(r)/n:+.4f} Summe={sum(r):+.1f}")
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 8000
dead=2.0
if not mt5.initialize(): print("init",mt5.last_error()); sys.exit(1)
sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
sym=sym or "SpotCrude"
KS=[5,10,20]
bars=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,n+_N_BARS+max(KS)+5)
mt5.shutdown()
if bars is None: print("Bars fehlen"); 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]
print("="*72)
print(f" Reversal-Test (überdehnt + Winkel gedreht) — {sym} M5 _STRETCH_MAX={_STRETCH_MAX}")
print("="*72)
for K in KS:
rev=[]; rev_long=[]; rev_short=[]; fade_noang=[]
for i in range(_N_BARS,len(C)-K):
wc=C[i-_N_BARS:i]; wh=H[i-_N_BARS:i]; wl=L[i-_N_BARS:i]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: continue
ef=_ema_last(wc,_EMA_FAST); es=_ema_last(wc,_EMA_SLOW)
stretch=(C[i-1]-es)/atr
if abs(stretch) < _STRETCH_MAX: # nur ÜBERDEHNTE Lagen (sonst normal)
continue
ang=calc_trend_angle(C[i-_LR-2:i], _LR)
down_over = stretch <= -_STRETCH_MAX # weit unter EMA
up_over = stretch >= _STRETCH_MAX # weit über EMA
d=0
if down_over and ang >= 90+dead: d=1 # Rebound long
elif up_over and ang <= 90-dead: d=-1 # Rebound short
if d!=0:
r=(C[i+K]-C[i])*d
rev.append(r); (rev_long if d>0 else rev_short).append(r)
else:
# Überdehnt, aber Winkel NICHT gedreht → reines Fade gegen Überdehnung
df = 1 if down_over else -1
fade_noang.append((C[i+K]-C[i])*df)
print(f"\nVorlauf {K} Bars:")
_rep("Reversal (Winkel best.)", rev)
_rep(" davon LONG", rev_long); _rep(" davon SHORT", rev_short)
_rep("Fade OHNE Winkel (Kontrolle)", fade_noang)
print("\nHinweis: überdehnte Lagen sind selten → kleine n, mit Vorsicht lesen.")
if __name__=="__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Misst den Edge von regulären Trendsignalen, die kurz NACH einem gegenläufigen
Reversal kommen (z.B. SHORT wenige Bars nach REV-LONG = in den Bounce shorten).
Sind die schwach/negativ → ein Reversal-Lockout (Gegenrichtung N Bars sperren)
würde genau diese Verlierer (wie der 26-€-Trade) verhindern.
"""
import sys
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (WaveRecommender, _atr, _ema_last, _EMA_FAST, _EMA_SLOW,
_N_BARS, _HTF_DEADBAND, _ANGLE_LR)
class _TU:
def snapshot(self): return {"intervals": {}}
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 _rep(name,r):
if not r: print(f" {name:<26} -"); return
n=len(r); w=sum(1 for x in r if x>0)
print(f" {name:<26} n={n:>4} Treffer={100*w/n:>3.0f}% Oe-Edge={sum(r)/n:+.4f}")
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 12000
K=10
mt5.initialize()
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+_N_BARS+K+5)
m30=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M30,0,n//6+400)
mt5.shutdown()
T=[int(b["time"]) for b in bars]; H=[float(b["high"]) for b in bars]
L=[float(b["low"]) for b in bars]; C=[float(b["close"]) for b in bars]
mT=[int(b["time"]) for b in m30]; mc=[float(b["close"]) for b in m30]
mh=[float(b["high"]) for b in m30]; ml=[float(b["low"]) for b in m30]
mEf=_ema_series(mc,_EMA_FAST); mEs=_ema_series(mc,_EMA_SLOW); mA=_atr_series(mh,ml,mc)
def m30s(ts):
lo,hi,idx=0,len(mT)-1,-1
while lo<=hi:
md=(lo+hi)//2
if mT[md]<=ts: idx=md; lo=md+1
else: hi=md-1
if idx<_EMA_SLOW or mA[idx] is None or mA[idx]<=0: return 0
d=mEf[idx]-mEs[idx]
return 0 if abs(d)<_HTF_DEADBAND*mA[idx] else (1 if d>0 else -1)
w=WaveRecommender(_TU(), mt5.TIMEFRAME_M5)
print("="*64); print(f" Reversal-Lockout-Test — {sym} M5+M30 Vorlauf={K}"); print("="*64)
for N in (6,12,24):
last_rev_bar=-999; last_rev_dir=0
counter=[]; normal=[]; rev=[]
for i in range(_N_BARS, len(C)-K):
wc=C[i-_N_BARS:i]; wh=H[i-_N_BARS:i]; wl=L[i-_N_BARS:i]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: continue
ef=_ema_last(wc,_EMA_FAST); es=_ema_last(wc,_EMA_SLOW)
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR)
rec,_=w._build(ef,es,C[i-1],atr,"M5",5,htf_trend=m30s(T[i]),angle=ang)
sig=rec["signal"]
if sig=="WARTEN": continue
d=1 if sig=="LONG" else -1
r=(C[i+K]-C[i])*d
is_rev="REV" in (rec.get("setup") or "")
if is_rev:
rev.append(r); last_rev_bar=i; last_rev_dir=d
else:
if (i-last_rev_bar)<=N and last_rev_dir!=0 and last_rev_dir!=d:
counter.append(r) # Gegenrichtung kurz nach Reversal
else:
normal.append(r)
print(f"\nLockout-Fenster N={N} Bars:")
_rep("Reversal selbst", rev)
_rep(f"Gegen-Signal <= {N} nach Rev", counter)
_rep("Normale Signale (Rest)", normal)
if __name__=="__main__":
main()
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""H1-Stärke-Filter (Diagnose: SHORT-Empfehlungen verlieren 31 % WR in Aufwärts-Regime):
Verwirft ein Signal, das GEGEN einen STARKEN H1-Trend läuft (|H1 EMA12EMA50| > thr×ATR_H1).
Das ist NICHT der schon verworfene H1-WINKEL-Filter, sondern die EMA-Trend-STÄRKE.
Signale kommen aus der echten `_build`-Logik (wie live). Exit = live-Modell (SL 2,0×ATR
+ Trailing 1,5, Breakeven 1,3). Kosten = echter Bar-Spread/ATR. 2 Halbjahre.
Entscheidung: Filter NUR bauen, wenn die WEGGEFILTERTEN Signale in BEIDEN Hälften
negativen Ø-R haben UND der Gesamt-ΣR in beiden steigt. Haben sie positiven Edge
(wie 6× zuvor), macht der Filter den Bot schlechter → verwerfen.
"""
import sys, bisect
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (WaveRecommender, _atr, _ema_last, _ema_series,
_EMA_FAST, _EMA_SLOW, _N_BARS, _HTF_DEADBAND, _ANGLE_LR)
_MAXH = 288; _ATRMIN = 0.12
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 sim(entry, d, atr, H, L, C, j0, sl_atr=2.0, trail=1.5, trail_on=0.3, be_on=1.3):
eff = entry - d*sl_atr*atr; hw = entry
end = min(j0+_MAXH, len(C)-1); exit_px = C[end]
for j in range(j0, end+1):
hi, lo = H[j], L[j]
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
if prof >= trail_on*atr:
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)
return (exit_px-entry)*d/atr
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 99.9), sum=s)
def line(lbl, s):
if not s: return f" {lbl:<24}"
return (f" {lbl:<24} n={s['n']:>4} WR={s['wr']:>3.0f}% ØR={s['oR']:+.3f} "
f"PF={s['pf']:>4.2f} ΣR={s['sum']:>+6.0f}")
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+_N_BARS+_MAXH+5)
m30 = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M30, 0, n//6+500)
h1 = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_H1, 0, n//12+500)
si = mt5.symbol_info(sym); point = si.point
mt5.shutdown()
T = [int(b["time"]) for b in bars]; 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]
def series(rr):
t = [int(b["time"]) for b in rr]; c = [float(b["close"]) for b in rr]
hh = [float(b["high"]) for b in rr]; ll = [float(b["low"]) for b in rr]
return t, _ema_series(c, _EMA_FAST), _ema_series(c, _EMA_SLOW), _atr_series(hh, ll, c)
mT, mEf, mEs, mA = series(m30)
hT, hEf, hEs, hA = series(h1)
def m30s(ts):
i = bisect.bisect_right(mT, ts)-1
if i < _EMA_SLOW or mA[i] is None or mA[i] <= 0: return 0
dd = mEf[i]-mEs[i]
return 0 if abs(dd) < _HTF_DEADBAND*mA[i] else (1 if dd > 0 else -1)
def h1_strength(ts):
"""H1-EMA-Abstand in ATR (signiert; >0 = aufwärts)."""
i = bisect.bisect_right(hT, ts)-1
if i < _EMA_SLOW or hA[i] is None or hA[i] <= 0: return 0.0
return (hEf[i]-hEs[i])/hA[i]
w = WaveRecommender(type("T", (), {"snapshot": lambda s: {"intervals": {}}})(), mt5.TIMEFRAME_M5)
sigs = [] # (idx, d, atr, h1_strength)
def cost(i, atr): return (SP[i] if SP[i] > 0 else 0.0225)/atr
for i in range(_N_BARS, len(C)-_MAXH-1):
wc = C[i-_N_BARS:i]; wh = H[i-_N_BARS:i]; wl = L[i-_N_BARS:i]
atr = _atr(wh, wl, wc)
if not atr or atr <= 0: continue
ef = _ema_last(wc, _EMA_FAST); es = _ema_last(wc, _EMA_SLOW)
a5 = calc_trend_angle(C[i-_ANGLE_LR-2:i], _ANGLE_LR)
rec, _ = w._build(ef, es, C[i-1], atr, "M5", 0, htf_trend=m30s(T[i]), angle=a5)
if rec["signal"] == "WARTEN": continue
d = 1 if rec["signal"] == "LONG" else -1
sigs.append((i, d, max(atr, _ATRMIN), h1_strength(T[i])))
mid = len(C)//2
print("="*82)
print(f" H1-Stärke-Filter — {sym} M5 ({len(sigs)} Signale · Exit live · Echtkosten · 2 Halbjahre)")
print(f" Filter verwirft Signal, wenn es GEGEN einen H1-Trend > thr×ATR läuft.")
print("="*82)
for lbl, lo, hi in (("H1 (alt)", 0, mid), ("H2 (neu)", mid, len(C))):
seg = [(i, d, a, h1) for (i, d, a, h1) in sigs if lo <= i < hi]
base = [sim(C[i], d, a, H, L, C, i+1) - cost(i, a) for (i, d, a, h1) in seg]
print(f"\n{lbl}:")
print(line("BASIS (alle Signale)", st(base)))
for thr in (0.5, 1.0, 1.5):
# gegen starken H1: SHORT & H1 stark auf ODER LONG & H1 stark ab
drop = [(i, d, a) for (i, d, a, h1) in seg
if (d < 0 and h1 > thr) or (d > 0 and h1 < -thr)]
keep = [(i, d, a) for (i, d, a, h1) in seg
if not ((d < 0 and h1 > thr) or (d > 0 and h1 < -thr))]
dR = [sim(C[i], d, a, H, L, C, i+1) - cost(i, a) for (i, d, a) in drop]
kR = [sim(C[i], d, a, H, L, C, i+1) - cost(i, a) for (i, d, a) in keep]
sd = st(dR); sk = st(kR)
print(f" thr={thr}×ATR:")
print(line(" WEGGEFILTERT (drop)", sd) + (" <- Edge<0 = Filter OK" if sd and sd["oR"] < 0 else ""))
print(line(" BEHALTEN (keep)", sk))
print(f"\n Filter NUR bauen, wenn 'drop' ØR<0 & ΣR<0 in BEIDEN Hälften (dann hebt")
print(f" Entfernen den Gesamt-ΣR). Positiver drop-Edge = die Pullbacks tragen → verwerfen.")
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""
backtest_signal.py — Edge-Selbsttest der Empfehlung
====================================================
Rechnet die ECHTE Empfehlungslogik (core/wave_rec.WaveRecommender._build)
über die letzten N historischen Bars nach und misst, ob der Kurs DANACH in
Signalrichtung läuft. So siehst du jederzeit, ob die Empfehlung auf dem
aktuellen Markt einen Vorhersagewert (Edge) hat.
Ø-Edge > 0 → Empfehlung trägt (Kurs folgt dem Signal)
Ø-Edge ≈ 0 → kein Edge (Zufall)
Ø-Edge < 0 → Signal läuft verkehrt
Aufruf (läuft parallel zum Server, nur Lese-Zugriff auf die MT5-History):
python backtest_signal.py # M15, 3000 Bars, Vorlauf 4/6/10
python backtest_signal.py M5 2000 # andere TF / Bar-Anzahl
python backtest_signal.py M15 3000 8 # fester Vorlauf 8 Bars
TU-Bestätigung wird im Test neutral gesetzt (historisch nicht verfügbar) —
gemessen wird also der reine Richtungs-Edge der Logik; live kommt die
TU-Bestätigung noch obendrauf.
"""
from __future__ import annotations
import sys
import MetaTrader5 as mt5
from core.wave_rec import (WaveRecommender, _atr, _ema_last,
_EMA_FAST, _EMA_SLOW, _N_BARS)
_TF = {"M1": mt5.TIMEFRAME_M1, "M5": mt5.TIMEFRAME_M5, "M15": mt5.TIMEFRAME_M15,
"M30": mt5.TIMEFRAME_M30, "H1": mt5.TIMEFRAME_H1}
class _NeutralTU:
def snapshot(self):
return {"intervals": {}}
def _report(name: str, rets: list[float]) -> None:
if not rets:
print(f" {name:<8} keine Signale"); return
n = len(rets)
win = sum(1 for x in rets if x > 0)
print(f" {name:<8} n={n:>4} Treffer={100*win/n:>3.0f}% "
f"Ø-Edge={sum(rets)/n:+.4f} Summe={sum(rets):+.2f}")
def main():
tf_lbl = (sys.argv[1].upper() if len(sys.argv) > 1 else "M15")
n_bars = int(sys.argv[2]) if len(sys.argv) > 2 else 3000
ks = [int(sys.argv[3])] if len(sys.argv) > 3 else [4, 6, 10]
if tf_lbl not in _TF:
print(f"Unbekannte TF '{tf_lbl}'. Erlaubt: {', '.join(_TF)}"); sys.exit(1)
tf = _TF[tf_lbl]
if not mt5.initialize():
print(f"MT5-Init fehlgeschlagen: {mt5.last_error()}"); sys.exit(1)
sym = None
for cand in ("SpotCrude", "USOIL", "WTI", "XTIUSD"):
if mt5.symbol_info(cand):
sym = cand; break
if sym is None:
from core.config import load_config
sym = load_config()["trading"].get("last_symbol", "").strip() or "SpotCrude"
bars = mt5.copy_rates_from_pos(sym, tf, 0, n_bars + _N_BARS + max(ks) + 5)
mt5.shutdown()
if bars is None or len(bars) < _N_BARS + 50:
print("Zu wenige Bars von MT5 erhalten."); 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]
w = WaveRecommender(_NeutralTU(), tf)
print("=" * 60)
print(f" Empfehlungs-Edge — {sym} {tf_lbl}, {len(C)} Bars")
print("=" * 60)
for K in ks:
res = {"LONG": [], "SHORT": [], "WARTEN": 0}
for i in range(_N_BARS, len(C) - K):
win_c = C[i - _N_BARS:i]; win_h = H[i - _N_BARS:i]; win_l = L[i - _N_BARS:i]
if len(win_c) < _EMA_SLOW + 5:
continue
atr = _atr(win_h, win_l, win_c)
if not atr or atr <= 0:
continue
ef = _ema_last(win_c, _EMA_FAST)
es = _ema_last(win_c, _EMA_SLOW)
rec, _ = w._build(ef, es, C[i - 1], atr, tf_lbl, 5)
sig = rec["signal"]
if sig == "WARTEN":
res["WARTEN"] += 1; continue
fwd = C[i + K] - C[i]
res[sig].append(fwd if sig == "LONG" else -fwd)
print(f"\nVorlauf {K} Bars (~{K} × {tf_lbl}):")
_report("LONG", res["LONG"])
_report("SHORT", res["SHORT"])
_report("ALLE", res["LONG"] + res["SHORT"])
print(f" WARTEN (kein Trade): {res['WARTEN']}")
print("\nØ-Edge > 0 = Empfehlung hat Vorhersagewert | ≈0 = Zufall | <0 = verkehrt")
if __name__ == "__main__":
main()
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Sizing-Simulation: was bringt 96%-All-in vs. Bruchteil-Margin vs. risiko-basiert
für das LANGFRIST-Wachstum (Endkapital) und den Max-Drawdown?
Modell (skalenfrei, fixe Bruchteil-Sizing → geometrisches Wachstum):
- Signale: echte _build-Logik (≥55% Konf, M30-Filter, Winkel) — wie live.
- Exit: SL fix 2,0×ATR + Trailing-TP (= aktuelles Live-Modell), pessimistisch.
- Pro Trade R (=Profit/ATR) → EUR-Rendite je nach Lots/Margin (echte MT5-Params).
- Endkapital = Π(1+ret_i) (bei fixer Bruchteil-Sizing ordnungs-UNABHÄNGIG).
- Max-Drawdown: historische Reihenfolge + Monte-Carlo (Shuffle) für die Verteilung.
Kernfrage: 96%-All-in liegt vermutlich WEIT über dem Kelly-Optimum → höhere
Bruchteil-Sizing gibt mehr Endkapital UND weniger Drawdown (Vola-Drag).
"""
import sys, random, math
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (WaveRecommender, _atr, _ema_last, _EMA_FAST, _EMA_SLOW,
_N_BARS, _HTF_DEADBAND, _ANGLE_LR)
_MAXH=240; _TRAILON=0.3; _TPTRAIL=0.5; _ATRMIN=0.12
_SL_ATR=2.0 # fixer Initial-SL (Live-Mitte des Bands)
_MARGIN_CAP=0.96 # nie mehr als 96% Margin (auch risiko-basiert gedeckelt)
class _TU:
def snapshot(self): return {"intervals": {}}
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 simulate(entry,d,atr,sl,H,L,C,j0):
eff=sl; hw=entry; trail=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]
if (lo<=eff) if d>0 else (hi>=eff):
return (eff-entry)*d/atr
hw=max(hw,hi) if d>0 else min(hw,lo)
if (C[j]-entry)*d>=_TRAILON*atr: trail=True
if trail:
lock=hw-d*_TPTRAIL*atr
eff=max(eff,lock) if d>0 else min(eff,lock)
return (exit_px-entry)*d/atr
def curve_stats(rets):
"""Endkapital-Faktor, Wachstum/Trade (mean ln), Max-DD (hist. Reihenfolge)."""
eq=1.0; peak=1.0; mdd=0.0; gsum=0.0; ruin=False
for r in rets:
r=max(r,-0.99)
eq*=(1+r); gsum+=math.log(max(1+r,1e-9))
peak=max(peak,eq); dd=1-eq/peak
if dd>mdd: mdd=dd
if eq<0.10*1.0: ruin=True
return eq, gsum/len(rets), mdd, ruin
def mc_maxdd(rets, runs=400):
"""Monte-Carlo: Verteilung des Max-DD über zufällige Trade-Reihenfolgen."""
dds=[]
base=list(rets)
for _ in range(runs):
random.shuffle(base)
eq=1.0; peak=1.0; mdd=0.0
for r in base:
eq*=(1+max(r,-0.99)); peak=max(peak,eq); mdd=max(mdd,1-eq/peak)
dds.append(mdd)
dds.sort()
p=lambda q: dds[min(len(dds)-1,int(q*len(dds)))]
over80=sum(1 for x in dds if x>0.80)/len(dds)
return p(0.50), p(0.95), over80
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 12000
mt5.initialize()
sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
si=mt5.symbol_info(sym); acc=mt5.account_info()
tick=mt5.symbol_info_tick(sym)
price_now=(tick.bid+tick.ask)/2
margin1=mt5.order_calc_margin(mt5.ORDER_TYPE_BUY, sym, 1.0, price_now)
vpp=(si.trade_tick_value or 1.0)/(si.trade_tick_size or si.point) # € je 1.0 Preis je Lot
lev=vpp*price_now/margin1 if margin1 else 0
bars=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,n+_N_BARS+_MAXH+5)
m30=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M30,0,n//6+400)
mt5.shutdown()
T=[int(b["time"]) for b in bars]; H=[float(b["high"]) for b in bars]
L=[float(b["low"]) for b in bars]; C=[float(b["close"]) for b in bars]
mT=[int(b["time"]) for b in m30]; mc=[float(b["close"]) for b in m30]
mh=[float(b["high"]) for b in m30]; ml=[float(b["low"]) for b in m30]
mEf=_ema_series(mc,_EMA_FAST); mEs=_ema_series(mc,_EMA_SLOW); mA=_atr_series(mh,ml,mc)
def m30s(ts):
lo,hi,idx=0,len(mT)-1,-1
while lo<=hi:
md=(lo+hi)//2
if mT[md]<=ts: idx=md; lo=md+1
else: hi=md-1
if idx<_EMA_SLOW or mA[idx] is None or mA[idx]<=0: return 0
dd=mEf[idx]-mEs[idx]
return 0 if abs(dd)<_HTF_DEADBAND*mA[idx] else (1 if dd>0 else -1)
w=WaveRecommender(_TU(), mt5.TIMEFRAME_M5)
trades=[] # (price_i, atr_i, R_i)
for i in range(_N_BARS, len(C)-_MAXH-1):
wc=C[i-_N_BARS:i]; wh=H[i-_N_BARS:i]; wl=L[i-_N_BARS:i]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: continue
ef=_ema_last(wc,_EMA_FAST); es=_ema_last(wc,_EMA_SLOW)
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR)
rec,_=w._build(ef,es,C[i-1],atr,"M5",5,htf_trend=m30s(T[i]),angle=ang)
if rec["signal"]=="WARTEN": continue
d=1 if rec["signal"]=="LONG" else -1
atr=max(atr,_ATRMIN); e=C[i]
R=simulate(e,d,atr,e-d*_SL_ATR*atr,H,L,C,i+1)
trades.append((e,atr,R))
def rets_for(rule, val):
out=[]
for (e,atr,R) in trades:
margin_i=margin1*e/price_now
if rule=="margin":
lots=val/margin_i # je 1 € Equity
else: # risk: val=Risiko-Anteil bei _SL_ATR-Stop
lots=val/(_SL_ATR*atr*vpp)
if lots*margin_i>_MARGIN_CAP: lots=_MARGIN_CAP/margin_i
out.append(lots*(R*atr)*vpp) # frakt. Rendite/Trade
return out
print("="*88)
print(f" Sizing-Simulation — {sym} Trades={len(trades)} SL fix {_SL_ATR}×ATR + Trailing-TP")
print(f" Hebel≈{lev:.0f}× · Margin/Lot≈{margin1:.2f} · Wert/1.0Preis/Lot≈{vpp:.2f}{acc.currency} · Kurs {price_now:.2f}")
print("="*88)
hdr=f" {'Sizing':<22}{'Endkapital':>12}{'Wachstum/Tr':>13}{'MaxDD-hist':>11}{'MaxDD-MC95':>11}{'P(DD>80%)':>11}"
print(hdr); print(" "+"-"*84)
rules=[("Margin 96% (AKTUELL)","margin",0.96),("Margin 50%","margin",0.50),
("Margin 30%","margin",0.30),("Margin 20%","margin",0.20),
("Margin 10%","margin",0.10),
("Risiko 1%/Trade","risk",0.01),("Risiko 2%/Trade","risk",0.02),
("Risiko 3%/Trade","risk",0.03)]
for name,rule,val in rules:
rets=rets_for(rule,val)
eq,g,mdd,ruin=curve_stats(rets)
mdd50,mdd95,over80=mc_maxdd(rets)
eqs=(f"{eq:.2e}×" if (eq>=1e4 or eq<1e-2) else f"{eq:7.2f}×")
print(f" {name:<22}{eqs:>12}{g:>+13.4f}{100*mdd:>10.0f}%{100*mdd95:>10.0f}%{100*over80:>10.0f}%"
+ (" ⚠RUIN" if ruin else ""))
# Kelly-Scan: welcher Margin-Bruchteil maximiert das Wachstum/Trade?
print("\n Kelly-Scan (Margin-Bruchteil → Wachstum pro Trade, ln):")
best=(-9,0)
for f in [x/100 for x in range(2,99,2)]:
g=curve_stats(rets_for("margin",f))[1]
if g>best[0]: best=(g,f)
print(f" Wachstum-Optimum bei Margin ≈ {best[1]*100:.0f}% (g={best[0]:+.4f}/Trade)")
g96=curve_stats(rets_for("margin",0.96))[1]
print(f" Zum Vergleich 96%: g={g96:+.4f}/Trade → "
+ ("96% liegt ÜBER dem Optimum (Vola-Drag)" if g96<best[0] else "96% ist ~optimal"))
print("\n Endkapital = Faktor auf Startkapital über alle Trades (≈"
f"{len(trades)} Trades ~{len(trades)//50} Handelstage). MaxDD-MC95 = 95%-Quantil"
" des Drawdowns über zufällige Reihenfolgen. P(DD>80%) = Quasi-Ruin-Risiko.")
if __name__=="__main__":
main()
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Vergleicht SL-PLATZIERUNGEN unter dem aktuellen Live-Exit (SL FIX + Trailing-TP,
_TRAIL_SL=False). Nur die Initial-SL-Position variiert, der Exit-Mechanismus ist
identisch → fairer Vergleich.
S/R-Proxy = jüngstes Swing-Extrem (Pivot) über _SRWIN Bars — das ist die Struktur,
an der auch der Live-Pivot hängt (echte historische Web-/Zone-Level liegen nicht
vor, deshalb dieser Proxy).
Exit-Modell (konservativ, pessimistisch: Gegenlauf VOR Mitlauf):
- SL fix bei Initial-Distanz bis Profit >= 0.3xATR
- danach Profit-Lock = HW ∓ 0.5xATR (= das Live-Trailing-TP), Ratsche
- Halt <= _MAXH Bars
PnL in R (= Profit / ATR), zusätzlich Ø-SL-Distanz, Frühstopp%, Worst-R.
"""
import sys
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (WaveRecommender, _atr, _ema_last, _EMA_FAST, _EMA_SLOW,
_N_BARS, _HTF_DEADBAND, _ANGLE_LR)
from core.config import INIT_SL_MIN_ATR, INIT_SL_MAX_ATR
_MAXH = 240
_TRAILON= 0.3 # ab hier Profit-Lock aktiv
_TPTRAIL= 0.5 # _TP_TRAIL_ATR
_SRWIN = 24 # Lookback für Swing-Extrem (S/R-Proxy), ~2h M5
_BUFTCK = 0.02 # Tick-Puffer Methode "Pivot" (~live SL_BUFFER_TICKS)
_ATRMIN = 0.12
class _TU:
def snapshot(self): return {"intervals": {}}
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 clamp_dist(dist, atr):
return max(INIT_SL_MIN_ATR*atr, min(dist, INIT_SL_MAX_ATR*atr))
def simulate(entry, d, atr, sl, H, L, C, j0):
"""Fixed-SL + Trailing-Profit-Lock. Rückgabe (R, init_stop)."""
eff = sl; hw = entry; trail = 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]
hit = (lo <= eff) if d>0 else (hi >= eff) # Gegenlauf zuerst
if hit:
exit_px = eff; return (exit_px-entry)*d/atr, (not trail)
hw = max(hw,hi) if d>0 else min(hw,lo)
if (C[j]-entry)*d >= _TRAILON*atr: trail = True
if trail:
lock = hw - d*_TPTRAIL*atr
eff = max(eff,lock) if d>0 else min(eff,lock)
return (exit_px-entry)*d/atr, False
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 12000
mt5.initialize()
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+_N_BARS+_MAXH+5)
m30=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M30,0,n//6+400)
mt5.shutdown()
T=[int(b["time"]) for b in bars]; H=[float(b["high"]) for b in bars]
L=[float(b["low"]) for b in bars]; C=[float(b["close"]) for b in bars]
mT=[int(b["time"]) for b in m30]; mc=[float(b["close"]) for b in m30]
mh=[float(b["high"]) for b in m30]; ml=[float(b["low"]) for b in m30]
mEf=_ema_series(mc,_EMA_FAST); mEs=_ema_series(mc,_EMA_SLOW); mA=_atr_series(mh,ml,mc)
def m30s(ts):
lo,hi,idx=0,len(mT)-1,-1
while lo<=hi:
md=(lo+hi)//2
if mT[md]<=ts: idx=md; lo=md+1
else: hi=md-1
if idx<_EMA_SLOW or mA[idx] is None or mA[idx]<=0: return 0
dd=mEf[idx]-mEs[idx]
return 0 if abs(dd)<_HTF_DEADBAND*mA[idx] else (1 if dd>0 else -1)
w=WaveRecommender(_TU(), mt5.TIMEFRAME_M5)
sigs=[]
for i in range(max(_N_BARS,_SRWIN), len(C)-_MAXH-1):
wc=C[i-_N_BARS:i]; wh=H[i-_N_BARS:i]; wl=L[i-_N_BARS:i]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: continue
ef=_ema_last(wc,_EMA_FAST); es=_ema_last(wc,_EMA_SLOW)
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR)
rec,_=w._build(ef,es,C[i-1],atr,"M5",5,htf_trend=m30s(T[i]),angle=ang)
if rec["signal"]=="WARTEN": continue
d=1 if rec["signal"]=="LONG" else -1
atr=max(atr,_ATRMIN)
# S/R-Proxy = jüngstes Swing-Extrem über _SRWIN Bars
sr = min(L[i-_SRWIN:i]) if d>0 else max(H[i-_SRWIN:i])
sigs.append((i, d, atr, C[i], sr))
# SL-Platzierungs-Methoden: dist(entry,sr,atr,d) → SL-Distanz
def m_pivot(e,sr,atr,d): return clamp_dist(abs(e-sr)+_BUFTCK, atr) # ~aktuell
def m_sr_atr(e,sr,atr,d): return clamp_dist(abs(e-sr)+0.3*atr, atr) # S/R ∓0.3ATR, gedeckelt
def m_sr_010c(e,sr,atr,d):return clamp_dist(abs(e-sr)+0.10, atr) # S/R ∓0.10, gedeckelt
def m_sr_010u(e,sr,atr,d):return max(0.5*atr, abs(e-sr)+0.10) # S/R ∓0.10, UNgedeckelt
def m_fix20(e,sr,atr,d): return 2.0*atr # fest 2.0×ATR
methods=[("Aktuell Pivot+Band",m_pivot),("S/R ∓0.3ATR (gedeckelt)",m_sr_atr),
("S/R ∓0.10 (gedeckelt)",m_sr_010c),("S/R ∓0.10 (UNgedeckelt)",m_sr_010u),
("fest 2.0×ATR",m_fix20)]
print("="*92)
print(f" SL-Platzierung — {sym} M5+M30 Signale={len(sigs)} Exit: SL fix + Trailing-TP")
print(f" Band [{INIT_SL_MIN_ATR}{INIT_SL_MAX_ATR}]×ATR · S/R-Proxy=Swing über {_SRWIN} Bars · pessimistisch")
print("="*92)
print(f" {'Methode':<26}{'Treffer':>8}{'Ø-R':>8}{'Summe-R':>9}{'PF':>6}"
f"{'Ø-Verl':>8}{'Worst':>8}{'Frühstop':>9}{'Ø-SLdist':>10}")
for name,fn in methods:
Rs=[]; dists=[]; istop=0
for (i,d,atr,e,sr) in sigs:
dist=fn(e,sr,atr,d)
sl=e-d*dist
R,ist=simulate(e,d,atr,sl,H,L,C,i+1)
Rs.append(R); dists.append(dist/atr)
if ist: istop+=1
n2=len(Rs); win=sum(1 for r in Rs if r>0)
g=sum(r for r in Rs if r>0); ls=-sum(r for r in Rs if r<0)
lo=[r for r in Rs if r<0]; pf=g/ls if ls>0 else 99.9
print(f" {name:<26}{100*win/n2:>7.0f}%{sum(Rs)/n2:>8.3f}{sum(Rs):>9.1f}"
f"{pf:>6.2f}{(sum(lo)/len(lo) if lo else 0):>8.2f}{min(Rs):>8.2f}"
f"{100*istop/n2:>8.0f}%{sum(dists)/len(dists):>9.2f}×")
if __name__=="__main__":
main()
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""SL-ATR-Timeframe-Mismatch bei Squeeze-Trades (2026-07-23, User-Frage nach
89,36-€-Nacht-Trade): Der VALIDIERTE Squeeze-Backtest (`backtest_breakout_squeeze.py`)
sizt den SL auf **M5-ATR** (2,0×) — dieselbe TF wie das Signal. LIVE sizt `trader.
_calc_sl_tp` den Initial-SL aber IMMER auf **M15-ATR** (`SL_TF=M15`, Band 1,82,2×),
unabhängig vom Signal-TF. Bei einem Squeeze (M5-Signal) kann das stark divergieren —
real letzte Nacht: M5-ATR fiel von 0,29→0,10 (Vola-Kompression, die den Squeeze
überhaupt erst auslöste!), während M15-ATR bei ~0,36 blieb → SL 0,787 statt ~0,20-0,22
bei M5-Sizing = ~3,5× zu weit für GENAU dieses Setup.
Test: Squeeze-Entries (Box/Ausbruch wie `backtest_breakout_squeeze.py`), SL/Trailing/
BE-Distanz aus ZWEI Quellen vergleichen:
LIVE = SL 2,2×ATR(M15) zum Entry-Zeitpunkt (min 1,8×, wie `_calc_sl_tp`), Trailing
bleibt M5-basiert (wie live, TF folgt dem Signal).
MODEL = SL 2,2×ATR(M5) — dieselbe TF wie das Signal (= was der Original-Backtest
validiert hat).
2 Halbjahre, Echtkosten. Verdict: nur wechseln, wenn MODEL in BEIDEN Hälften ΣR/PF
schlägt.
"""
import sys
import MetaTrader5 as mt5
_MAXH = 288; _ATRMIN = 0.12
_N = 12; _W = 24; _COOL = 12; _K = 0.1
_SL_MIN = 1.8; _SL_MAX = 2.2
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])))
out = [None]
for i in range(1, len(C)):
seg = t[max(1, i-p+1):i+1]
out.append(sum(seg)/len(seg))
return out
def sim(entry, d, sl_dist, atr_trail, H, L, C, j0, trail=1.5, trail_on=0.3, be_on=1.3):
"""sl_dist = absolute Preisdistanz (schon TF-spezifisch berechnet). Trailing/BE
laufen wie live auf atr_trail (M5, das Signal-TF) — nur der INITIALE SL variiert."""
eff = entry - d*sl_dist; hw = entry
end = min(j0+_MAXH, len(C)-1); exit_px = C[end]
for j in range(j0, end+1):
hi, lo = H[j], L[j]
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
if prof >= trail_on*atr_trail:
cand = hw - d*trail*atr_trail
if prof >= be_on*atr_trail: cand = max(cand, entry) if d > 0 else min(cand, entry)
eff = max(eff, cand) if d > 0 else min(eff, cand)
return (exit_px-entry)*d/atr_trail
def rep(name, Rs):
if not Rs: print(f" {name:<28}"); return
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
print(f" {name:<28} n={n:>4} WR={100*w/n:>3.0f}% ØR={s/n:+.3f} PF={pf:.2f} ΣR={s:+.0f}")
def run(H, L, C, A5, A15_at, SP, lo_i, hi_i, squeeze_mult):
def cost(i, atr): return (SP[i] if SP[i] > 0 else 0.0225)/atr
live_Rs, model_Rs = [], []
i = max(lo_i, _N+15)
while i < min(hi_i, len(C)-_MAXH-1):
atr5 = A5[i]
if not atr5 or atr5 < _ATRMIN: i += 1; continue
boxHi = max(H[i-_N:i]); boxLo = min(L[i-_N:i]); box = boxHi-boxLo
if box > squeeze_mult*atr5: i += 1; continue
hit = None
for j in range(i, min(i+_W, len(C)-_MAXH-1)):
up = boxHi + _K*atr5; dn = boxLo - _K*atr5
if H[j] >= up: hit = (j, 1, up); break
if L[j] <= dn: hit = (j, -1, dn); break
if hit is None: i += 1; continue
j, d, lvl = hit
atr5_j = A5[j] or atr5
atr15_j = A15_at(j)
c = cost(j, atr5_j)
# LIVE: SL aus M15-ATR (wie trader._calc_sl_tp), Band [1.8,2.2], Trailing auf M5
if atr15_j:
sl_live = min(_SL_MAX, max(_SL_MIN, _SL_MAX)) * atr15_j # live nutzt fix 2.2 (Cap)
sl_live = _SL_MAX * atr15_j
live_Rs.append(sim(lvl, d, sl_live, atr5_j, H, L, C, j+1) - c)
# MODEL: SL aus M5-ATR (Signal-TF, wie der validierte Original-Backtest)
sl_model = _SL_MAX * atr5_j
model_Rs.append(sim(lvl, d, sl_model, atr5_j, H, L, C, j+1) - c)
i = j + _COOL
return live_Rs, model_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)
m5 = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, n+_MAXH+30)
m15 = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M15, 0, (n+_MAXH+30)//3+50)
si = mt5.symbol_info(sym); point = si.point
mt5.shutdown()
H = [float(b["high"]) for b in m5]; L = [float(b["low"]) for b in m5]
C = [float(b["close"]) for b in m5]; SP = [float(b["spread"])*point for b in m5]
T = [int(b["time"]) for b in m5]
A5 = _atr_series(H, L, C)
H15 = [float(b["high"]) for b in m15]; L15 = [float(b["low"]) for b in m15]
C15 = [float(b["close"]) for b in m15]; T15 = [int(b["time"]) for b in m15]
A15 = _atr_series(H15, L15, C15)
# Für jeden M5-Index den ZULETZT ABGESCHLOSSENEN M15-ATR nachschlagen (wie live
# copy_rates_from_pos "jetzt" die letzten M15-Bars holt) — simple Vorwärts-Suche.
import bisect
def A15_at(i5):
t = T[i5]
k = bisect.bisect_right(T15, t) - 1
return A15[k] if 0 <= k < len(A15) and A15[k] else None
N = len(C); mid = N//2
print("="*90)
print(f" SL-ATR-TF-Mismatch — {sym} M5 Squeeze-Entries ({N} Bars, 2 Halbjahre, Echtkosten)")
print(f" LIVE = SL 2,2×ATR(M15, fix) vs MODEL = SL 2,2×ATR(M5, Signal-TF, validierte Basis)")
print("="*90)
for lbl, lo, hi in (("H1 (alt)", 0, mid), ("H2 (neu)", mid, N)):
live_Rs, model_Rs = run(H, L, C, A5, A15_at, SP, lo, hi, 2.5)
print(f"\n {lbl}:")
rep("LIVE (M15-ATR-SL)", live_Rs)
rep("MODEL (M5-ATR-SL)", model_Rs)
if live_Rs and model_Rs:
print(f" Δ ΣR (ModelLive): {sum(model_Rs)-sum(live_Rs):+.0f}")
print(f"\n Verdict: MODEL nur übernehmen, wenn es in BEIDEN Hälften ΣR/PF schlägt.")
if __name__ == "__main__":
main()
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Squeeze-als-EXIT (Variante B, 2026-07-22, User-Idee „Gegen-Position beim Ausbruch
schneller schließen"). Reine EXIT-Regel — KEIN Re-Entry (anders als der verworfene
Stop-&-Reverse): Halte ich eine Position und feuert ein **validierter Squeeze-Ausbruch
in GEGENrichtung**, schließe ich sofort (statt bis zum SL/Trailing zu laufen).
Test: gleiche Entries (frischer EMA12/50-Cross M5), zwei Exits vergleichen:
BASE = Live-Exit (SL 2,0×ATR + Trailing 1,5 + BE 1,3).
MOD = BASE, aber Gegen-Squeeze-Ausbruch schließt die Position sofort (am Bar-Close).
MOD-Verlust = wie MOD, aber nur wenn die Position im MINUS ist (Verlust begrenzen).
Squeeze mit den LIVE-Params (_SQ_N/_SQ_MULT/_SQ_K). Kosten = Bar-Spread/ATR am Entry.
Innerhalb eines Bars: erst SL/Trailing (intrabar H/L), dann Squeeze (Close) — der
Squeeze-Exit greift also nur, wenn der SL im Bar NICHT schon lief.
Verdict: MOD baut nur ein, wenn ΣR in BEIDEN Hälften ≥ BASE (verbessert den Exit,
kostet keine der Hälften). Sonst raus (wie der Reverse).
"""
import sys
import MetaTrader5 as mt5
from core.wave_rec import _SQ_N, _SQ_MULT, _SQ_K
_MAXH = 288; _ATRMIN = 0.06; _COOL = 6
_EMA_F = 12; _EMA_S = 50; _DEAD = 0.15
def _ema(C, p):
k = 2.0 / (p + 1); e = C[0]; out = [e]
for x in C[1:]:
e += k * (x - e); out.append(e)
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 _sq_dir(H, L, C, A):
"""Squeeze-Ausbruchs-Richtung je Bar (+1 LONG / 1 SHORT / 0), Live-Params —
Box = _SQ_N Bars VOR i (abgeschlossen), aktueller Close bricht ±_SQ_K×ATR."""
out = [0] * len(C)
for i in range(_SQ_N + 1, len(C)):
atr = A[i]
if not atr or atr <= 0:
continue
hi = max(H[i-_SQ_N:i]); lo = min(L[i-_SQ_N:i])
if (hi - lo) / atr > _SQ_MULT: # keine Kompression
continue
if C[i] >= hi + _SQ_K * atr: out[i] = 1
elif C[i] <= lo - _SQ_K * atr: out[i] = -1
return out
def sim(entry, d, atr, H, L, C, j0, SQ, mode):
"""mode: 'base' | 'mod' (Gegen-Squeeze schließt) | 'modl' (nur im Minus)."""
eff = entry - d*2.0*atr; hw = entry
end = min(j0+_MAXH, len(C)-1); exit_px = C[end]; exit_j = end
for j in range(j0, end+1):
hj, lj = H[j], L[j]
if (lj <= eff) if d > 0 else (hj >= eff): # SL/Trailing (intrabar)
return (eff-entry)*d/atr, j
if mode != 'base' and SQ[j] == -d: # Gegen-Squeeze am Close
if mode == 'mod' or (C[j]-entry)*d < 0: # 'modl': nur wenn im Minus
return (C[j]-entry)*d/atr, j
hw = max(hw, hj) if d > 0 else min(hw, lj)
prof = (C[j]-entry)*d
if prof >= 0.3*atr:
cand = hw - d*1.5*atr
if prof >= 1.3*atr: cand = max(cand, entry) if d > 0 else min(cand, entry)
eff = max(eff, cand) if d > 0 else min(eff, cand)
return (exit_px-entry)*d/atr, exit_j
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:<14}"
return (f" {lbl:<14} n={s['n']:>4} WR={s['wr']:>3.0f}% ØR={s['oR']:+.3f} "
f"PF={s['pf']:>4.2f} ΣR={s['sum']:>+6.0f}")
def run(H, L, C, A, SP, EF, ES, SQ, lo, hi, mode):
def cost(i, atr): return (SP[i] if SP[i] > 0 else 0.0225)/atr
Rs = []; i = max(lo, _EMA_S + 2)
sig_prev = 0
while i < hi:
atr = A[i]
if not atr or atr < _ATRMIN:
i += 1; continue
diff = EF[i] - ES[i]; dead = _DEAD * atr
sig = 1 if diff > dead else -1 if diff < -dead else 0
if sig != 0 and sig != sig_prev: # frischer Cross
r, xj = sim(C[i], sig, max(atr, _ATRMIN), H, L, C, i+1, SQ, mode)
Rs.append(r - cost(i, max(atr, _ATRMIN)))
sig_prev = sig
i = xj + _COOL; continue
if sig != 0: sig_prev = sig
i += 1
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); EF = _ema(C, _EMA_F); ES = _ema(C, _EMA_S)
SQ = _sq_dir(H, L, C, A); N = len(C); mid = N//2
print("="*80)
print(f" Squeeze-als-EXIT (Variante B) — {sym} M5 ({N} Bars, EMA-Entries, Echtkosten)")
print(f" BASE=Live-Exit · MOD=Gegen-Squeeze schließt · MODL=nur im Minus. 2 Halbjahre.")
print("="*80)
for lbl, lo, hi in (("H1 (alt)", 0, mid), ("H2 (neu)", mid, N-_MAXH-1)):
base = st(run(H, L, C, A, SP, EF, ES, SQ, lo, hi, 'base'))
mod = st(run(H, L, C, A, SP, EF, ES, SQ, lo, hi, 'mod'))
modl = st(run(H, L, C, A, SP, EF, ES, SQ, lo, hi, 'modl'))
print(f"\n {lbl}:")
print(line("BASE", base))
print(line("MOD (immer)", mod))
print(line("MODL (Minus)", modl))
if base and mod and modl:
print(f" Δ ΣR: MOD {mod['sum']-base['sum']:+.0f} MODL {modl['sum']-base['sum']:+.0f}"
f" (>0 = besser als BASE)")
print(f"\n Verdict: einbauen nur, wenn eine MOD-Variante in BEIDEN Hälften ΣR ≥ BASE.")
if __name__ == "__main__":
main()
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""User-Regel messen: „P(Level-Durchbruch) > 60 % → laufen lassen, sonst close."
Dazu 2 Fragen (Track B, Echtkosten, 2 Hälften):
(1) Wie hoch ist die Basisrate P(Durchbruch) beim ERSTEN Touch des Ziel-Levels —
und können Merkmale am Touch (Trend, Momentum, Touch-Zahl, Tick-Volumen) sie
robust über/unter 60 % schieben? Break = nach Touch +0,5×ATR JENSEITS des
Levels binnen 12 Bars, bevor 0,5×ATR zurückfällt.
(2) Entscheidungs-relevant: Ø(R_run R_close) je Merkmal-Segment — gibt es ein
Segment, in dem CLOSE am Level das Weiterlaufen (Trailing) robust schlägt?
Wenn kein Merkmal P(break) verlässlich trennt bzw. kein Segment robust pro-Close
ist, kollabiert die Regel zu „immer laufen lassen" (= Trailing, schon gemessen).
"""
import sys
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (_EMA_FAST, _EMA_SLOW, _N_BARS, _ANGLE_LR, _ANGLE_DEAD,
_REVERSAL_STRETCH, _STRETCH_MAX)
_MAXH=200; _ATRMIN=0.12; _SL_ATR=2.0; _TRAILON=0.3; _MULT=1.5; _BE=1.3
_LOCK_START=3.5; _LOCK_SCALE=0.6; _LOCK_MIN=1.2; _TP_INIT=3.5
_PIV_K=3; _LOOKBACK=300; _BRK_W=12; _BRK_ATR=0.5
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 sim_run(entry,d,atr,H,L,C,j0,level):
"""Baseline-Trailing bis Ende; liefert (R_final, j_touch|None) — Touch = erstes
Erreichen des Levels, solange der Trade offen ist."""
sl=entry-d*_SL_ATR*atr; tp=entry+d*_TP_INIT*atr
hw=entry; rank=0; jt=None
end=min(j0+_MAXH,len(C)-1)
for j in range(j0,end+1):
hi,lo=H[j],L[j]
if (lo<=sl) if d>0 else (hi>=sl): return (sl-entry)*d/atr, jt
if jt is None and level is not None and ((hi>=level) if d>0 else (lo<=level)):
jt=j
if (hi>=tp) if d>0 else (lo<=tp): return (tp-entry)*d/atr, jt
hw=max(hw,hi) if d>0 else min(hw,lo)
profit=(hw-entry)*d
ph=0 if profit<_TRAILON*atr else (1 if profit<_LOCK_START*atr else 2)
if ph<rank: ph=rank
rank=ph
if ph==1:
cand=hw-d*_MULT*atr
if profit>=_BE*atr:
cand=max(cand,entry) if d>0 else min(cand,entry)
sl=max(sl,cand) if d>0 else min(sl,cand)
elif ph==2:
tm=max(_LOCK_MIN,_MULT*_LOCK_SCALE)
cand=hw-d*tm*atr
cand=max(cand,entry) if d>0 else min(cand,entry)
sl=max(sl,cand) if d>0 else min(sl,cand)
return (C[end]-entry)*d/atr, jt
def is_break(level,d,atr,H,L,jt):
up=level+d*_BRK_ATR*atr; dn=level-d*_BRK_ATR*atr
for j in range(jt, min(jt+_BRK_W, len(H))):
if (H[j]>=up) if d>0 else (L[j]<=up): return True
if (L[j]<=dn) if d>0 else (H[j]>=dn): return False
return False
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 80000
mt5.initialize(); sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
si=mt5.symbol_info(sym); point=si.point
bars=None
for req in (n,80000,60000,40000):
bars=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,req)
if bars is not None and len(bars)>2000: break
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]; V=[float(b["tick_volume"]) for b in bars]
SP=[float(b["spread"])*point for b in bars]
EF=_ema_series(C,_EMA_FAST); ES=_ema_series(C,_EMA_SLOW); AT=_atr_series(H,L,C)
mid=len(C)//2; TH=_REVERSAL_STRETCH
print("="*96)
print(f" P(Level-Durchbruch) & Close-vs-Run am Touch — {sym} M5 ({len(C)} Bars, Echtkosten)")
print("="*96)
for lbl,a,b in (("H1 (alt)",_N_BARS,mid),("H2 (neu)",mid,len(C))):
ev=[] # (break?, dR = R_run R_close, feature-dict)
for i in range(max(a,_N_BARS,_LOOKBACK), min(b,len(C)-_MAXH-1)):
atr=AT[i]
if not atr or atr<=0: continue
atr=max(atr,_ATRMIN); es=ES[i]; ef=EF[i]
stretch=(C[i]-es)/atr
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR); ad=ang-90.0
d=0
if stretch<=-TH and ad>=_ANGLE_DEAD: d=1
elif stretch>=TH and ad<=-_ANGLE_DEAD: d=-1
elif abs(stretch)<_STRETCH_MAX: d=1 if ef>es else -1 if ef<es else 0
if not d: continue
entry=C[i]
# nächstes gegenüberliegendes Pivot-Level
phis,plos=[],[]
for j in range(i-_LOOKBACK+_PIV_K, i-_PIV_K):
if H[j]==max(H[j-_PIV_K:j+_PIV_K+1]): phis.append(H[j])
if L[j]==min(L[j-_PIV_K:j+_PIV_K+1]): plos.append(L[j])
if d>0:
cands=[p for p in phis if p>entry+0.3*atr]
level=min(cands) if cands else None
near=phis
else:
cands=[p for p in plos if p<entry-0.3*atr]
level=max(cands) if cands else None
near=plos
if level is None: continue
R_run,jt=sim_run(entry,d,atr,H,L,C,i+1,level)
if jt is None: continue # Level nie erreicht
R_close=(level-entry)*d/atr
brk=is_break(level,d,atr,H,L,jt)
mom=(C[jt]-C[max(0,jt-6)])*d/atr
touches=sum(1 for p in near if abs(p-level)<=0.15*atr)
volr=V[jt]/max(1.0,(sum(V[max(0,jt-20):jt])/max(1,len(V[max(0,jt-20):jt]))))
withtrend=1 if (EF[jt]-ES[jt])*d>0 else 0
ev.append((brk, R_run-R_close,
{"mom":mom,"touch":touches,"vol":volr,"wt":withtrend}))
n_ev=len(ev); pb=100*sum(1 for e in ev if e[0])/max(1,n_ev)
dall=sum(e[1] for e in ev)/max(1,n_ev)
print(f"\n{lbl}: {n_ev} Level-Touches · Basisrate P(break)={pb:.0f}% · Ø(R_runR_close)={dall:+.3f}")
segs=[("mit Trend",lambda f:f["wt"]==1),("gegen Trend",lambda f:f["wt"]==0),
("Momentum ≥0,3",lambda f:f["mom"]>=0.3),("Momentum <0,3",lambda f:f["mom"]<0.3),
("Level ≥2 Touches",lambda f:f["touch"]>=2),("Level 1 Touch",lambda f:f["touch"]<2),
("Volumen-Spike ≥1,5×",lambda f:f["vol"]>=1.5),("Volumen normal",lambda f:f["vol"]<1.5)]
print(f" {'Segment':<20}{'n':>7}{'P(break)':>10}{'Ø(runclose)':>14}")
for name,fn in segs:
sub=[e for e in ev if fn(e[2])]
if not sub: continue
p=100*sum(1 for e in sub if e[0])/len(sub)
dd=sum(e[1] for e in sub)/len(sub)
mark=" ← CLOSE besser" if dd<0 else ""
print(f" {name:<20}{len(sub):>7}{p:>9.0f}%{dd:>+14.3f}{mark}")
print("\n Die Regel trägt nur, wenn ein Segment in BEIDEN Hälften Ø(runclose)<0 zeigt")
print(" (= dort wäre Close besser) UND P(break) dort klar unter der Basisrate liegt.")
if __name__=="__main__":
main()
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""S/R-Close-Empfehlung messen (Track B): Lohnt es, am nächsten gegenüberliegenden
S/R-Level zu SCHLIESSEN statt das Trailing laufen zu lassen?
S/R = Pivot-Hochs/-Tiefs (k Bars beidseitig) der letzten LOOKBACK Bars vor Entry.
Varianten (je LONG: nächster Pivot-High-Level ÜBER Entry als Ziel):
V1 Close am S/R-Ziel, sobald Level ≥0,3×ATR über Entry (hart)
V2 Close am S/R-Ziel nur wenn Level ≥1,0×ATR über Entry (nur „echte" Ziele)
BAS Live-Exit (SL 2,0 + Trailing 1,5 + BE 1,3 + Lock)
Kosten je Trade = echter Bar-Spread/ATR. 2 History-Hälften. Historie: Auto-Close an
S/R wurde früher schon einmal verworfen; hier Neu-Messung mit Echtkosten.
"""
import sys
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (_EMA_FAST, _EMA_SLOW, _N_BARS, _ANGLE_LR, _ANGLE_DEAD,
_REVERSAL_STRETCH, _STRETCH_MAX)
_MAXH=200; _ATRMIN=0.12; _SL_ATR=2.0; _TRAILON=0.3; _MULT=1.5; _BE=1.3
_LOCK_START=3.5; _LOCK_SCALE=0.6; _LOCK_MIN=1.2; _TP_INIT=3.5
_PIV_K=3; _LOOKBACK=300
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 pivots(H,L,i0,i1):
"""Pivot-Hochs/-Tiefs in [i0,i1) (k Bars beidseitig bestätigt)."""
hi,lo=[],[]
for j in range(max(i0,_PIV_K), i1-_PIV_K):
if H[j]==max(H[j-_PIV_K:j+_PIV_K+1]): hi.append(H[j])
if L[j]==min(L[j-_PIV_K:j+_PIV_K+1]): lo.append(L[j])
return hi,lo
def sim(entry,d,atr,H,L,C,j0, sr_target=None):
"""Live-Exit; optional zusätzlich Voll-Close bei Berührung von sr_target."""
sl=entry-d*_SL_ATR*atr; tp=entry+d*_TP_INIT*atr
hw=entry; rank=0
end=min(j0+_MAXH,len(C)-1)
for j in range(j0,end+1):
hi,lo=H[j],L[j]
if (lo<=sl) if d>0 else (hi>=sl): return (sl-entry)*d/atr
if sr_target is not None and ((hi>=sr_target) if d>0 else (lo<=sr_target)):
return (sr_target-entry)*d/atr # Close am S/R-Ziel
if (hi>=tp) if d>0 else (lo<=tp): return (tp-entry)*d/atr
hw=max(hw,hi) if d>0 else min(hw,lo)
profit=(hw-entry)*d
ph=0 if profit<_TRAILON*atr else (1 if profit<_LOCK_START*atr else 2)
if ph<rank: ph=rank
rank=ph
if ph==1:
cand=hw-d*_MULT*atr
if profit>=_BE*atr:
cand=max(cand,entry) if d>0 else min(cand,entry)
sl=max(sl,cand) if d>0 else min(sl,cand)
elif ph==2:
tm=max(_LOCK_MIN,_MULT*_LOCK_SCALE)
cand=hw-d*tm*atr
cand=max(cand,entry) if d>0 else min(cand,entry)
sl=max(sl,cand) if d>0 else min(sl,cand)
return (C[end]-entry)*d/atr
def stx(v):
if not v: return "n=0"
n=len(v); w=sum(1 for x in v if x>0)
g=sum(x for x in v if x>0); ls=-sum(x for x in v if x<0)
return (f"n={n:>5} WR={100*w/n:>3.0f}% Ø-R={sum(v)/n:+.3f} PF={(g/ls if ls>0 else 99):>4.2f} "
f"Worst={min(v):+.2f} ΣR={sum(v):+.0f}")
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 80000
mt5.initialize(); sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
si=mt5.symbol_info(sym); point=si.point
bars=None
for req in (n,80000,60000,40000):
bars=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,req)
if bars is not None and len(bars)>2000: break
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]
EF=_ema_series(C,_EMA_FAST); ES=_ema_series(C,_EMA_SLOW); AT=_atr_series(H,L,C)
mid=len(C)//2; TH=_REVERSAL_STRETCH
print("="*94)
print(f" S/R-Close vs Trailing — {sym} M5 ({len(C)} Bars) Pivots k={_PIV_K}, Lookback {_LOOKBACK} ECHTE Kosten")
print("="*94)
for lbl,a,b in (("H1 (alt)",_N_BARS,mid),("H2 (neu)",mid,len(C))):
base=[]; v1=[]; v2=[]; n_t1=0; n_t2=0
for i in range(max(a,_N_BARS,_LOOKBACK), min(b,len(C)-_MAXH-1)):
atr=AT[i]
if not atr or atr<=0: continue
atr=max(atr,_ATRMIN); es=ES[i]; ef=EF[i]
stretch=(C[i]-es)/atr
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR); ad=ang-90.0
d=0
if stretch<=-TH and ad>=_ANGLE_DEAD: d=1
elif stretch>=TH and ad<=-_ANGLE_DEAD: d=-1
elif abs(stretch)<_STRETCH_MAX: d=1 if ef>es else -1 if ef<es else 0
if not d: continue
cost=(SP[i] if SP[i]>0 else 0.0225)/atr
entry=C[i]
phis,plos=pivots(H,L,i-_LOOKBACK,i)
if d>0:
cands=[p for p in phis if p>entry+0.3*atr]
tgt=min(cands) if cands else None
else:
cands=[p for p in plos if p<entry-0.3*atr]
tgt=max(cands) if cands else None
base.append(sim(entry,d,atr,H,L,C,i+1)-cost)
v1.append(sim(entry,d,atr,H,L,C,i+1,sr_target=tgt)-cost)
if tgt is not None: n_t1+=1
tgt2=tgt if (tgt is not None and abs(tgt-entry)>=1.0*atr) else None
v2.append(sim(entry,d,atr,H,L,C,i+1,sr_target=tgt2)-cost)
if tgt2 is not None: n_t2+=1
print(f"\n{lbl} ({len(base)} Signale · V1-Ziel bei {n_t1} · V2-Ziel bei {n_t2}):")
print(f" BASELINE Trailing {stx(base)}")
print(f" V1 Close@S/R (≥0,3×ATR) {stx(v1)}")
print(f" V2 Close@S/R (≥1,0×ATR) {stx(v2)}")
print("\n V gewinnt nur, wenn ΣR/PF in BEIDEN Hälften über der Baseline liegen.")
if __name__=="__main__":
main()
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
"""R-Ertrag der P(break)-Schwellen-Regel messen (Track B, Echtkosten, 2 Hälften):
Regel: erreicht die Welle das gegenüberliegende Level, berechne P(Durchbruch) mit
dem kalibrierten Logit-Modell (auf H1 trainiert). Ist P < Schwelle X → am Level
CLOSEN; sonst laufen lassen (Live-Trailing). Baseline = immer laufen lassen.
Findet das R-Optimum und prüft die User-Schwelle 60 % (= closen wenn P<0,60).
"""
import sys
import numpy as np
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (_EMA_FAST, _EMA_SLOW, _N_BARS, _ANGLE_LR, _ANGLE_DEAD,
_REVERSAL_STRETCH, _STRETCH_MAX)
_MAXH=200; _ATRMIN=0.12; _SL_ATR=2.0; _TRAILON=0.3; _MULT=1.5; _BE=1.3
_LOCK_START=3.5; _LOCK_SCALE=0.6; _LOCK_MIN=1.2; _TP_INIT=3.5
_PIV_K=3; _LOOKBACK=300; _BRK_W=12; _BRK_ATR=0.5
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 sim_run(entry,d,atr,H,L,C,j0):
sl=entry-d*_SL_ATR*atr; tp=entry+d*_TP_INIT*atr; hw=entry; rank=0
end=min(j0+_MAXH,len(C)-1)
for j in range(j0,end+1):
hi,lo=H[j],L[j]
if (lo<=sl) if d>0 else (hi>=sl): return (sl-entry)*d/atr
if (hi>=tp) if d>0 else (lo<=tp): return (tp-entry)*d/atr
hw=max(hw,hi) if d>0 else min(hw,lo)
profit=(hw-entry)*d
ph=0 if profit<_TRAILON*atr else (1 if profit<_LOCK_START*atr else 2)
if ph<rank: ph=rank
rank=ph
if ph==1:
cand=hw-d*_MULT*atr
if profit>=_BE*atr: cand=max(cand,entry) if d>0 else min(cand,entry)
sl=max(sl,cand) if d>0 else min(sl,cand)
elif ph==2:
tm=max(_LOCK_MIN,_MULT*_LOCK_SCALE); cand=hw-d*tm*atr
cand=max(cand,entry) if d>0 else min(cand,entry)
sl=max(sl,cand) if d>0 else min(sl,cand)
return (C[end]-entry)*d/atr
def collect(a,b,H,L,C,EF,ES,AT,SP,point):
"""→ Liste dicts: R_run (baseline), touched, break, feat, R_close (bei Touch)."""
TH=_REVERSAL_STRETCH; out=[]
for i in range(max(a,_N_BARS,_LOOKBACK), min(b,len(C)-_MAXH-1)):
atr=AT[i]
if not atr or atr<=0: continue
atr=max(atr,_ATRMIN); es=ES[i]; ef=EF[i]
stretch=(C[i]-es)/atr
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR); ad=ang-90.0
d=0
if stretch<=-TH and ad>=_ANGLE_DEAD: d=1
elif stretch>=TH and ad<=-_ANGLE_DEAD: d=-1
elif abs(stretch)<_STRETCH_MAX: d=1 if ef>es else -1 if ef<es else 0
if not d: continue
entry=C[i]; cost=(SP[i] if SP[i]>0 else 0.0225)/atr
R_run=sim_run(entry,d,atr,H,L,C,i+1)-cost
rec={"R_run":R_run,"touched":False}
phis,plos=[],[]
for j in range(i-_LOOKBACK+_PIV_K, i-_PIV_K):
if H[j]==max(H[j-_PIV_K:j+_PIV_K+1]): phis.append(H[j])
if L[j]==min(L[j-_PIV_K:j+_PIV_K+1]): plos.append(L[j])
if d>0:
cands=[p for p in phis if p>entry+0.3*atr]; level=min(cands) if cands else None
else:
cands=[p for p in plos if p<entry-0.3*atr]; level=max(cands) if cands else None
if level is not None:
jt=None
for j in range(i+1, min(i+_MAXH, len(C)-_BRK_W)):
if ((H[j]>=level) if d>0 else (L[j]<=level)): jt=j; break
if ((entry-L[j]) if d>0 else (H[j]-entry)) >= 2.0*atr: break
if jt is not None:
up=level+d*_BRK_ATR*atr; dn=level-d*_BRK_ATR*atr; brk=False
for j in range(jt, min(jt+_BRK_W, len(H))):
if (H[j]>=up) if d>0 else (L[j]<=up): brk=True; break
if (L[j]<=dn) if d>0 else (H[j]>=dn): brk=False; break
rec.update(touched=True, brk=1.0 if brk else 0.0,
R_close=(level-entry)*d/atr-cost,
feat=[(C[jt]-C[max(0,jt-6)])*d/atr,(C[jt]-C[max(0,jt-3)])*d/atr,
1.0 if (EF[jt]-ES[jt])*d>0 else 0.0, abs(level-entry)/atr])
out.append(rec)
return out
def fit_logreg(X,y,iters=3000,lr=0.3):
n,m=X.shape; Xb=np.hstack([np.ones((n,1)),X]); w=np.zeros(m+1)
for _ in range(iters):
p=1/(1+np.exp(-(Xb@w))); w-=lr*(Xb.T@(p-y))/n
return w
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 80000
mt5.initialize(); sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
si=mt5.symbol_info(sym); point=si.point
bars=None
for req in (n,80000,60000,40000):
bars=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,req)
if bars is not None and len(bars)>2000: break
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]
EF=_ema_series(C,_EMA_FAST); ES=_ema_series(C,_EMA_SLOW); AT=_atr_series(H,L,C)
mid=len(C)//2
ev1=collect(_N_BARS,mid,H,L,C,EF,ES,AT,SP,point)
ev2=collect(mid,len(C),H,L,C,EF,ES,AT,SP,point)
# Modell auf H1-Touches trainieren
t1=[e for e in ev1 if e["touched"]]
X1=np.array([e["feat"] for e in t1]); y1=np.array([e["brk"] for e in t1])
mu=X1.mean(0); sd=X1.std(0)+1e-9
w=fit_logreg((X1-mu)/sd,y1)
def pbreak(e):
x=(np.array(e["feat"])-mu)/sd
return 1/(1+np.exp(-(w@np.concatenate([[1.0],x]))))
print("="*94)
print(f" P(break)-Close-Regel — R-Ertrag (Echtkosten, Trailing-Baseline) — {sym} M5")
print(f" Modell auf H1 trainiert · Koeff mom6={w[1]:+.2f} mom3={w[2]:+.2f} wt={w[3]:+.2f} dist={w[4]:+.2f}")
print("="*94)
for lbl,ev in (("H1 (alt)",ev1),("H2 (neu)",ev2)):
base=sum(e["R_run"] for e in ev)
tset=[e for e in ev if e["touched"]]
for e in tset: e["_p"]=pbreak(e)
print(f"\n{lbl}: {len(ev)} Wellen · {len(tset)} Level-Touches · Baseline (immer laufen) ΣR={base:+.0f}")
print(f" {'Schwelle X':<12}{'%laufen':>9}{'ΣR Regel':>10}{'Δ vs Baseline':>15}")
for X in (0.30,0.35,0.40,0.45,0.50,0.55,0.60,0.70):
run=sum(1 for e in tset if e["_p"]>=X)
total=sum((e["R_close"] if e["_p"]<X else e["R_run"]) for e in tset) \
+ sum(e["R_run"] for e in ev if not e["touched"])
mark=" ← deine 60%" if abs(X-0.60)<1e-9 else ""
print(f" P≥{X:.2f} {100*run/max(1,len(tset)):>7.0f}%{total:>10.0f}{total-base:>+15.0f}{mark}")
# Mindestgewinn-Variante (User-Frage): am Level nur closen, wenn der Close
# mindestens minR (netto, ×ATR) bringt — sonst weiterlaufen lassen.
print(f" {'Mindestgewinn (P<0.60)':<24}{'%geclosed':>10}{'ΣR Regel':>10}{'Δ vs minR=0':>13}")
base_rule=sum((e["R_close"] if e["_p"]<0.60 else e["R_run"]) for e in tset) \
+ sum(e["R_run"] for e in ev if not e["touched"])
for minR in (0.0,0.1,0.2,0.3,0.5,0.8):
closed=sum(1 for e in tset if e["_p"]<0.60 and e["R_close"]>=minR)
total=sum((e["R_close"] if (e["_p"]<0.60 and e["R_close"]>=minR) else e["R_run"])
for e in tset) + sum(e["R_run"] for e in ev if not e["touched"])
print(f" minR={minR:.1f}×ATR {100*closed/max(1,len(tset)):>8.0f}%{total:>10.0f}{total-base_rule:>+13.0f}")
if __name__=="__main__":
main()
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""S/R-Close SIGNAL-gegated (User-Idee 2026-07-22): den S/R-Close (P(break)<0,60)
UNTERDRÜCKEN, solange die Empfehlung noch in Trade-Richtung zeigt — dann laufen
lassen (bis das Signal auf WARTEN dreht). Test-Frage: bringt „bei mit-Trend am
Level HALTEN statt schließen" mehr als der reine P(break)-Close?
Reuse der gemessenen S/R-Close-Mechanik aus `backtest_srclose_prob.py`:
R_run = laufen lassen (Trailing-Exit) · R_close = am Level schließen
feat[2] = 1.0 wenn EMA am Touch in Trade-Richtung (= „Empfehlung LONG/SHORT"),
0.0 sonst (= Empfehlung neutral/gegen → ~WARTEN).
Regeln je Level-Touch:
BASELINE = immer laufen lassen.
P(break) = close wenn P<0,60 (der aktuelle Live-Mechanismus).
USER-Regel = close wenn P<0,60 UND Empfehlung NICHT in Richtung (feat[2]==0);
bei mit-Trend HALTEN (laufen). Differenz nur bei den mit-Trend-Touches.
2 Halbjahre, Echtkosten. Verdict: USER-Regel nur besser, wenn ΣR in BEIDEN Hälften
über dem reinen P(break)-Close liegt.
"""
import sys
import numpy as np
import MetaTrader5 as mt5
from backtest_srclose_prob import collect, fit_logreg, _ema_series, _atr_series
from core.wave_rec import _EMA_FAST, _EMA_SLOW, _N_BARS
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)
point = mt5.symbol_info(sym).point
bars = None
for req in (n, 80000, 60000, 40000):
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, req)
if bars is not None and len(bars) > 2000:
break
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]
EF = _ema_series(C, _EMA_FAST); ES = _ema_series(C, _EMA_SLOW); AT = _atr_series(H, L, C)
mid = len(C)//2
ev1 = collect(_N_BARS, mid, H, L, C, EF, ES, AT, SP, point)
ev2 = collect(mid, len(C), H, L, C, EF, ES, AT, SP, point)
# P(break)-Modell auf H1-Touches trainieren (wie im Original)
t1 = [e for e in ev1 if e["touched"]]
X1 = np.array([e["feat"] for e in t1]); y1 = np.array([e["brk"] for e in t1])
mu = X1.mean(0); sd = X1.std(0)+1e-9
w = fit_logreg((X1-mu)/sd, y1)
def pbreak(e):
x = (np.array(e["feat"])-mu)/sd
return 1/(1+np.exp(-(w@np.concatenate([[1.0], x]))))
X = 0.60
print("="*88)
print(f" S/R-Close SIGNAL-gegated (bei mit-Trend HALTEN) — {sym} M5, 2 Halbjahre, Echtkosten")
print(f" Vergleich: reiner P(break)-Close (P<{X}) vs. deine Regel (nur closen wenn Empf. NICHT in Richtung)")
print("="*88)
for lbl, ev in (("H1 (alt)", ev1), ("H2 (neu)", ev2)):
for e in ev:
if e["touched"]: e["_p"] = pbreak(e)
untouched = sum(e["R_run"] for e in ev if not e["touched"])
tset = [e for e in ev if e["touched"]]
base = sum(e["R_run"] for e in ev) # immer laufen
pbrule = sum((e["R_close"] if e["_p"] < X else e["R_run"]) for e in tset) + untouched
# USER: close nur wenn P<X UND Empfehlung NICHT in Richtung (feat[2]<0.5)
user = sum((e["R_close"] if (e["_p"] < X and e["feat"][2] < 0.5) else e["R_run"])
for e in tset) + untouched
# Die betroffenen Touches: P<X UND mit-Trend (die deine Regel jetzt HÄLT statt closed)
held = [e for e in tset if e["_p"] < X and e["feat"][2] >= 0.5]
gv_close = sum(e["R_close"] for e in held); gv_run = sum(e["R_run"] for e in held)
print(f"\n {lbl}: {len(ev)} Wellen · {len(tset)} Touches")
print(f" Baseline (immer laufen) ΣR = {base:+.0f}")
print(f" P(break)-Close (Live) ΣR = {pbrule:+.0f} (Δ Baseline {pbrule-base:+.0f})")
print(f" DEINE Regel (mit-Trend halten) ΣR = {user:+.0f} (Δ P(break) {user-pbrule:+.0f})")
print(f" → betroffen: {len(held)} mit-Trend-Touches. Wenn geHALTEN: ΣR {gv_run:+.0f} "
f"vs. geschlossen: ΣR {gv_close:+.0f} → Halten {'BESSER' if gv_run>gv_close else 'SCHLECHTER'} "
f"({gv_run-gv_close:+.0f})")
print(f"\n Verdict: deine Regel nur einbauen, wenn 'Halten BESSER' in BEIDEN Hälften.")
if __name__ == "__main__":
main()
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Misst, ob ein Lockern der Überdehnungs-Grenze (_STRETCH_MAX) mehr Signale
mit weiterhin positivem Edge bringt. Live-Konfig M5 + M30-Filter.
Trick: _STRETCH_MAX hoch setzen (alle Trend-Signale zulassen), dann den Edge je
Abstand-zur-EMA-Band (near = stretch in Trade-Richtung) bucketen. So sieht man,
welche Trades ein höheres Limit ZUSÄTZLICH zuließe und ob sie tragen.
"""
from __future__ import annotations
import sys
import MetaTrader5 as mt5
import core.wave_rec as wr
from core.wave_rec import (WaveRecommender, _atr, _ema_last,
_EMA_FAST, _EMA_SLOW, _N_BARS, _HTF_DEADBAND)
class _NeutralTU:
def snapshot(self): return {"intervals": {}}
def _ema_series(vals,p):
k=2.0/(p+1); out=[]; e=vals[0]
for i,v in enumerate(vals):
e=v if i==0 else v*k+e*(1-k); out.append(e)
return out
def _atr_series(H,L,C,p=14):
trs=[0.0]
for i in range(1,len(C)):
trs.append(max(H[i]-L[i],abs(H[i]-C[i-1]),abs(L[i]-C[i-1])))
return [(sum(trs[max(1,i-p+1):i+1])/max(1,len(trs[max(1,i-p+1):i+1]))) if i else None for i in range(len(C))]
def _rep(name, rets):
if not rets: print(f" {name:<18} -"); return
n=len(rets); win=sum(1 for x in rets if x>0)
print(f" {name:<18} n={n:>4} Treffer={100*win/n:>3.0f}% Oe-Edge={sum(rets)/n:+.4f}")
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 8000
K=10
if not mt5.initialize(): print("init",mt5.last_error()); sys.exit(1)
sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
sym=sym or "SpotCrude"
bars=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,n+_N_BARS+K+5)
m30b=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M30,0,n//6+400)
mt5.shutdown()
if bars is None or m30b is None: print("Bars fehlen"); sys.exit(1)
T=[int(b["time"]) for b in bars]; H=[float(b["high"]) for b in bars]
L=[float(b["low"]) for b in bars]; C=[float(b["close"]) for b in bars]
mT=[int(b["time"]) for b in m30b]; mc=[float(b["close"]) for b in m30b]
mh=[float(b["high"]) for b in m30b]; ml=[float(b["low"]) for b in m30b]
mEf=_ema_series(mc,_EMA_FAST); mEs=_ema_series(mc,_EMA_SLOW); mA=_atr_series(mh,ml,mc)
def m30s(ts):
lo,hi,idx=0,len(mT)-1,-1
while lo<=hi:
md=(lo+hi)//2
if mT[md]<=ts: idx=md; lo=md+1
else: hi=md-1
if idx<_EMA_SLOW or mA[idx] is None or mA[idx]<=0: return 0
d=mEf[idx]-mEs[idx]
return 0 if abs(d)<_HTF_DEADBAND*mA[idx] else (1 if d>0 else -1)
wr._STRETCH_MAX = 999.0 # alle Trend-Signale zulassen
w=WaveRecommender(_NeutralTU(),mt5.TIMEFRAME_M5)
bands={"<2,5 (jetzt)":[], "2,5-3,0":[], "3,0-3,5":[], ">=3,5":[]}
total_bars=0
for i in range(_N_BARS,len(C)-K):
wc=C[i-_N_BARS:i]; wh=H[i-_N_BARS:i]; wl=L[i-_N_BARS:i]
atr=_atr(wh,wl,wc)
if not atr or atr<=0: continue
total_bars+=1
ef=_ema_last(wc,_EMA_FAST); es=_ema_last(wc,_EMA_SLOW)
rec,_=w._build(ef,es,C[i-1],atr,"M5",5,htf_trend=m30s(T[i]))
if rec["signal"]=="WARTEN": continue
d=1 if rec["signal"]=="LONG" else -1
r=(C[i+K]-C[i])*d
near=(C[i-1]-es)/atr*d
if near<2.5: bands["<2,5 (jetzt)"].append(r)
elif near<3.0: bands["2,5-3,0"].append(r)
elif near<3.5: bands["3,0-3,5"].append(r)
else: bands[">=3,5"].append(r)
print("="*56); print(f" Überdehnungs-Test — {sym} M5+M30 Vorlauf={K}"); print("="*56)
print("Edge je Abstand-Band (near = ATR-Abstand in Trade-Richtung):")
for k in ("<2,5 (jetzt)","2,5-3,0","3,0-3,5",">=3,5"): _rep(k,bands[k])
def cum(maxv):
rr=[]
for k,lim in (("<2,5 (jetzt)",2.5),("2,5-3,0",3.0),("3,0-3,5",3.5),(">=3,5",99)):
if lim<=maxv+1e-9: rr+=bands[k]
return rr
print("\nKumuliert je _STRETCH_MAX (Signal-Anteil aller Bars + Gesamt-Edge):")
for label,mx in (("2,5 (jetzt)",2.5),("3,0",3.0),("3,5",3.5),("aus",99)):
rr=cum(mx); share=100*len(rr)/total_bars
win=sum(1 for x in rr if x>0)
print(f" MAX={label:<10} Signale={len(rr):>4} ({share:>4.1f}% der Bars) "
f"Treffer={100*win/len(rr):>3.0f}% Oe-Edge={sum(rr)/len(rr):+.4f}")
if __name__=="__main__":
main()
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Marktstruktur als SIGNAL — Backtest (2026-07-20, User-Idee „Pro-Chart-Setup").
Testet die handelbaren Varianten der `structure.py`-Anzeige (nutzt DEREN Logik,
kausal Bar für Bar), damit Signal == Anzeige:
A) Kanal-Pullback (der orange Pfeil): im Aufwärts-Regressionskanal Kurs zurück
ans UNTERE Kanaldrittel (pos ≤ X) → LONG; Abwärtskanal + pos ≥ 1X → SHORT.
Frischer Eintritt in die Zone (pos kreuzt X), one-shot.
A2) wie A, aber ZUSÄTZLICH Swing-Struktur-Filter (Trend = HH/HL bzw. LH/LL).
B) BOS-Continuation: frischer Break of Structure in Kanalrichtung → Entry.
Sequentielle 1-Positions-Sim, Live-Exit (SL 2,0×ATR + Trailing 1,5 + BE 1,3),
Echtkosten = Bar-Spread/ATR. M30. 2 Halbjahre. Maßstab: Squeeze ØR +0,14…+0,23 &
PF>1 in BEIDEN Hälften. Verdict-Regel: Einbau nur bei Robustheit über beide Hälften
UND Parameter.
"""
import sys
import MetaTrader5 as mt5
from core.structure import _pivots, _classify, _channel, _atr as _atr_win
_MAXH = 96; _ATRMIN = 0.06; _COOL = 3; _LOOK = 220; _REG = 60
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:<30}"
return (f" {lbl:<30} n={s['n']:>4} WR={s['wr']:>3.0f}% ØR={s['oR']:+.3f} "
f"PF={s['pf']:>4.2f} ΣR={s['sum']:>+6.0f}")
def sim(entry, d, atr, H, L, C, j0):
eff = entry - d*2.0*atr; hw = entry
end = min(j0+_MAXH, len(C)-1); exit_px = C[end]; exit_j = end
for j in range(j0, end+1):
hj, lj = H[j], L[j]
if (lj <= eff) if d > 0 else (hj >= eff):
return (eff-entry)*d/atr, j
hw = max(hw, hj) if d > 0 else min(hw, lj)
prof = (C[j]-entry)*d
if prof >= 0.3*atr:
cand = hw - d*1.5*atr
if prof >= 1.3*atr: cand = max(cand, entry) if d > 0 else min(cand, entry)
eff = max(eff, cand) if d > 0 else min(eff, cand)
return (exit_px-entry)*d/atr, exit_j
def _trend_from_swings(C, i):
"""Swing-Trend (HH/HL→up, LH/LL→down) kausal über die letzten _LOOK Bars."""
lo = max(0, i-_LOOK)
piv = _pivots([0]*0 or None, None, 0) if False else None
return None # (Platzhalter, in run() direkt mit H/L berechnet)
def run(H, L, C, A, SP, lo, hi, rule, X):
def cost(i, atr): return (SP[i] if SP[i] > 0 else 0.0225)/atr
Rs = []
i = max(lo, _LOOK)
prev_pos = None
while i < hi:
atr = A[i]
if not atr or atr < _ATRMIN:
i += 1; prev_pos = None; continue
w0 = max(0, i-_REG+1)
ch = _channel(C[w0:i+1], atr)
if not ch:
i += 1; prev_pos = None; continue
pos = ch["pos"]; cdir = ch["dir"]
d = 0
if rule in ("A", "A2"):
# frischer Eintritt ins untere (up) bzw. obere (down) Kanaldrittel
if cdir == "up" and pos <= X and (prev_pos is None or prev_pos > X):
d = 1
elif cdir == "down" and pos >= 1-X and (prev_pos is None or prev_pos < 1-X):
d = -1
if d != 0 and rule == "A2":
# zusätzlich Swing-Struktur bestätigen
pl = _pivots(H[max(0, i-_LOOK):i+1], L[max(0, i-_LOOK):i+1], 3)
lab = _classify(pl)
recent = [x["type"] for x in lab[-4:]]
ups = sum(1 for t in recent if t in ("HH", "HL"))
dns = sum(1 for t in recent if t in ("LH", "LL"))
strend = "up" if ups >= 3 and ups > dns else "down" if dns >= 3 and dns > ups else "range"
if (d > 0 and strend != "up") or (d < 0 and strend != "down"):
d = 0
elif rule == "B":
# frischer BOS in Kanalrichtung
pl = _pivots(H[max(0, i-_LOOK):i+1], L[max(0, i-_LOOK):i+1], 3)
lab = _classify(pl)
from core.structure import _last_bos
bos = _last_bos(lab, i+1-max(0, i-_LOOK))
if bos and bos["bars_ago"] <= int(X): # X = max Bars seit BOS
if bos["dir"] == "up" and cdir != "down": d = 1
elif bos["dir"] == "down" and cdir != "up": d = -1
prev_pos = pos
if d == 0:
i += 1; continue
entry = C[i]
r, xj = sim(entry, d, max(atr, _ATRMIN), H, L, C, i+1)
Rs.append(r - cost(i, max(atr, _ATRMIN)))
i = xj + _COOL; prev_pos = None
return Rs
def main():
n = int(sys.argv[1]) if len(sys.argv) > 1 else 60000
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_M30, 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); N = len(C); mid = N//2
print("="*90)
print(f" Marktstruktur als SIGNAL — {sym} M30 ({N} Bars, seq. Sim, Live-Exit, Echtkosten)")
print(f" Maßstab: Squeeze ØR +0,14…+0,23 & PF>1 in BEIDEN Hälften. Verdict = beidhälftig robust.")
print("="*90)
tests = [("A Kanal-Pullback X=0.20", "A", 0.20),
("A Kanal-Pullback X=0.30", "A", 0.30),
("A2 +Swing-Filter X=0.25", "A2", 0.25),
("B BOS-Cont. ≤1 Bar", "B", 1),
("B BOS-Cont. ≤3 Bars", "B", 3)]
for lbl, rule, X in tests:
s1 = st(run(H, L, C, A, SP, 0, mid, rule, X))
s2 = st(run(H, L, C, A, SP, mid, N-_MAXH-1, rule, X))
print(f"\n {lbl}:")
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 (nicht beidseitig positiv)'}")
print()
if __name__ == "__main__":
main()
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Time-Stop-Messung (Track B, Exit-Hebel): Trade nach N Bars schließen, wenn er
bis dahin keinen Fortschritt gemacht hat (profit < thr×ATR) — Whipsaw-Opfer im Chop
früh raus, statt bis SL/Trailing zu bluten. Varianten N ∈ {6,12,24,48} M5-Bars ×
thr ∈ {0.0, 0.3}, gegen die Live-Exit-Baseline (SL 2×ATR + Trailing + BE), über
2 History-Hälften. Einbauen nur, wenn eine Variante in BEIDEN Hälften besser ist.
"""
import sys
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (_EMA_FAST, _EMA_SLOW, _N_BARS, _ANGLE_LR, _ANGLE_DEAD,
_REVERSAL_STRETCH, _STRETCH_MAX)
_MAXH=200; _ATRMIN=0.12; _SL_ATR=2.0; _TRAILON=0.3; _MULT=1.5; _BE=1.3
_LOCK_START=3.5; _LOCK_SCALE=0.6; _LOCK_MIN=1.2; _TP_INIT=3.5
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 sim(entry,d,atr,H,L,C,j0, ts_n=None, ts_thr=0.0):
"""Live-Phasen-Exit + optionaler Time-Stop bei Bar j0+ts_n (kein Fortschritt)."""
sl=entry-d*_SL_ATR*atr; tp=entry+d*_TP_INIT*atr
hw=entry; rank=0
end=min(j0+_MAXH,len(C)-1)
for j in range(j0,end+1):
hi,lo=H[j],L[j]
if (lo<=sl) if d>0 else (hi>=sl): return (sl-entry)*d/atr
if (hi>=tp) if d>0 else (lo<=tp): return (tp-entry)*d/atr
hw=max(hw,hi) if d>0 else min(hw,lo)
profit=(hw-entry)*d
if ts_n is not None and j-j0>=ts_n and profit<ts_thr*atr:
return (C[j]-entry)*d/atr # Time-Stop: kein Fortschritt → raus
ph=0 if profit<_TRAILON*atr else (1 if profit<_LOCK_START*atr else 2)
if ph<rank: ph=rank
rank=ph
if ph==1:
cand=hw-d*_MULT*atr
if profit>=_BE*atr:
cand=max(cand,entry) if d>0 else min(cand,entry)
sl=max(sl,cand) if d>0 else min(sl,cand)
elif ph==2:
tm=max(_LOCK_MIN,_MULT*_LOCK_SCALE)
cand=hw-d*tm*atr
cand=max(cand,entry) if d>0 else min(cand,entry)
sl=max(sl,cand) if d>0 else min(sl,cand)
return (C[end]-entry)*d/atr
def st(v):
if not v: return "n=0"
n=len(v); w=sum(1 for x in v if x>0)
g=sum(x for x in v if x>0); ls=-sum(x for x in v if x<0)
return (f"WR={100*w/n:>3.0f}% Ø-R={sum(v)/n:+.3f} PF={(g/ls if ls>0 else 99):>4.2f} "
f"Worst={min(v):+.2f} ΣR={sum(v):+.0f}")
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 80000
mt5.initialize(); sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
bars=None
for req in (n,100000,80000,60000,40000):
bars=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,req)
if bars is not None and len(bars)>2000: break
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]
EF=_ema_series(C,_EMA_FAST); ES=_ema_series(C,_EMA_SLOW); AT=_atr_series(H,L,C)
mid=len(C)//2; TH=_REVERSAL_STRETCH
SIG={}
for lbl,a,b in (("H1 (alt)",_N_BARS,mid),("H2 (neu)",mid,len(C))):
out=[]
for i in range(max(a,_N_BARS), min(b,len(C)-_MAXH-1)):
atr=AT[i]
if not atr or atr<=0: continue
atr=max(atr,_ATRMIN); es=ES[i]; ef=EF[i]
stretch=(C[i]-es)/atr
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR); ad=ang-90.0
d=0
if stretch<=-TH and ad>=_ANGLE_DEAD: d=1
elif stretch>=TH and ad<=-_ANGLE_DEAD: d=-1
elif abs(stretch)<_STRETCH_MAX: d=1 if ef>es else -1 if ef<es else 0
if d: out.append((i,d,atr))
SIG[lbl]=out
print("="*94)
print(f" Time-Stop — {sym} M5 ({len(C)} Bars) Exit ohne Fortschritt nach N Bars schließen")
print("="*94)
for lbl in SIG:
print(f"\n{lbl} ({len(SIG[lbl])} Signale):")
base=[sim(C[i],d,atr,H,L,C,i+1) for (i,d,atr) in SIG[lbl]]
print(f" Baseline (Live-Exit) {st(base)}")
for N in (6,12,24,48):
for thr in (0.0,0.3):
v=[sim(C[i],d,atr,H,L,C,i+1,ts_n=N,ts_thr=thr) for (i,d,atr) in SIG[lbl]]
print(f" N={N:>2} thr={thr:.1f}×ATR {st(v)}")
print("\n Einbauen nur, wenn eine Variante in BEIDEN Hälften ΣR UND Worst verbessert.")
if __name__=="__main__":
main()
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Prüft die TRAILING-Logik und Optimierungen über 2 Zeiträume (Track B).
Bildet die ECHTE Phasen-Mechanik nach (Init→Trail→Lock, HW-Ratsche, Breakeven-Boden)
und variiert die Schlüsselparameter:
- trail_start (ab welchem Profit der SL zu ratchen beginnt; live 0,3×ATR)
- mult (Trail-Abstand HWmult×ATR; live M5=1,5)
plus Baseline 'Fix-Stop' (SL 2×ATR, kein Trailing) und 'Breakeven-only'.
Signal-Set = Reversal + Trend (wie backtest_hourly). Metrik: Ø-R/PF/WR/ΣR je Hälfte.
Robust nur, wenn eine Variante in BEIDEN Hälften besser ist.
"""
import sys
import MetaTrader5 as mt5
from core.analysis import calc_trend_angle
from core.wave_rec import (_EMA_FAST, _EMA_SLOW, _N_BARS, _ANGLE_LR, _ANGLE_DEAD,
_REVERSAL_STRETCH, _STRETCH_MAX)
_MAXH=200; _ATRMIN=0.12; _TP_INIT=3.5; _LOCK_START=3.5; _LOCK_SCALE=0.6; _LOCK_MIN=1.2
_BE=1.3 # Breakeven-Boden (live)
def _ema_series(v,p):
k=2.0/(p+1); o=[]; e=v[0]
for i,x in enumerate(v): e=x if i==0 else x*k+e*(1-k); o.append(e)
return o
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 sim(entry,d,atr,H,L,C,j0, trail_start, mult, breakeven=_BE, trail=True):
"""Echte Phasen-Trailing-Sim. trail=False → fixer Stop entrymult×ATR (kein Ratchen)."""
sl = entry - d*mult*atr
tp = entry + d*_TP_INIT*atr
hw = entry; rank = 0
end=min(j0+_MAXH, len(C)-1)
for j in range(j0, end+1):
hi,lo=H[j],L[j]
hit_sl = (lo<=sl) if d>0 else (hi>=sl)
hit_tp = (hi>=tp) if d>0 else (lo<=tp)
if hit_sl: return (sl-entry)*d/atr # pessimistisch: SL vor TP im selben Bar
if hit_tp: return (tp-entry)*d/atr
hw = max(hw,hi) if d>0 else min(hw,lo)
if not trail: continue
profit=(hw-entry)*d
ph = 0 if profit<trail_start*atr else (1 if profit<_LOCK_START*atr else 2)
if ph<rank: ph=rank
rank=ph
if ph==1:
cand = hw - d*mult*atr
if profit>=breakeven*atr:
cand = max(cand,entry) if d>0 else min(cand,entry)
sl = max(sl,cand) if d>0 else min(sl,cand)
elif ph==2:
tm=max(_LOCK_MIN, mult*_LOCK_SCALE)
cand=hw - d*tm*atr
cand=max(cand,entry) if d>0 else min(cand,entry)
sl=max(sl,cand) if d>0 else min(sl,cand)
return (C[end]-entry)*d/atr
def st(Rs):
if not Rs: return " -"
n=len(Rs); w=sum(1 for r in Rs if r>0)
g=sum(r for r in Rs if r>0); ls=-sum(r for r in Rs if r<0)
return f"WR={100*w/n:>3.0f}% Ø-R={sum(Rs)/n:+.3f} PF={(g/ls if ls>0 else 99):>4.2f} Worst={min(Rs):+.2f} ΣR={sum(Rs):+.0f}"
def signals(H,L,C,ES,EF,AT,lo,hi,step):
TH=_REVERSAL_STRETCH; out=[]
for i in range(max(lo,_N_BARS), min(hi,len(C)-_MAXH-1), step):
atr=AT[i]
if not atr or atr<=0: continue
atr=max(atr,_ATRMIN); es=ES[i]; ef=EF[i]
stretch=(C[i]-es)/atr
ang=calc_trend_angle(C[i-_ANGLE_LR-2:i],_ANGLE_LR); ad=ang-90.0
d=0
if stretch<=-TH and ad>=_ANGLE_DEAD: d=1
elif stretch>=TH and ad<=-_ANGLE_DEAD: d=-1
elif abs(stretch)<_STRETCH_MAX: d=1 if ef>es else -1 if ef<es else 0
if d: out.append((i,d,atr))
return out
def main():
n=int(sys.argv[1]) if len(sys.argv)>1 else 60000
step=int(sys.argv[2]) if len(sys.argv)>2 else 2
mt5.initialize(); sym=None
for c in ("SpotCrude","USOIL","WTI","XTIUSD"):
if mt5.symbol_info(c): sym=c; break
bars=None
for req in (n,100000,80000,60000,40000):
bars=mt5.copy_rates_from_pos(sym,mt5.TIMEFRAME_M5,0,req)
if bars is not None and len(bars)>2000: break
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]
EF=_ema_series(C,_EMA_FAST); ES=_ema_series(C,_EMA_SLOW); AT=_atr_series(H,L,C)
mid=len(C)//2
halves=[("H1 (alt)",_N_BARS,mid),("H2 (neu)",mid,len(C))]
print("="*94)
print(f" TRAILING-Optimierung — {sym} M5 ({len(C)} Bars, step {step}) Signal=Trend+Reversal")
print(f" Live-Basis: trail_start=0,3 · mult=1,5 (M5) · Breakeven=1,3 · Lock ab 3,5×ATR")
print("="*94)
SIG={lbl: signals(H,L,C,ES,EF,AT,a,b,step) for lbl,a,b in halves}
for lbl,_,_ in halves: print(f" {lbl}: {len(SIG[lbl])} Signale")
variants = [("FIX-Stop 2,0 (kein Trail)", dict(trail_start=99, mult=2.0, trail=False)),
("Breakeven-only (mult=2,0)", dict(trail_start=_BE, mult=2.0, breakeven=_BE)),
("LIVE start0,3 mult1,5", dict(trail_start=0.3, mult=1.5)),
("start0,6 mult1,5", dict(trail_start=0.6, mult=1.5)),
("start1,0 mult1,5", dict(trail_start=1.0, mult=1.5)),
("start0,3 mult2,0", dict(trail_start=0.3, mult=2.0)),
("start1,0 mult2,0", dict(trail_start=1.0, mult=2.0)),
("start0,3 mult2,5", dict(trail_start=0.3, mult=2.5)),
("start1,3 mult2,5", dict(trail_start=1.3, mult=2.5))]
for name,kw in variants:
print(f"\n{name}")
for lbl,_,_ in halves:
Rs=[sim(C[i],d,atr,H,L,C,i+1, **kw) for (i,d,atr) in SIG[lbl]]
print(f" {lbl} {st(Rs)}")
if __name__=="__main__":
main()
+1
View File
@@ -0,0 +1 @@
"""Core-Module für den Oil Trading Server."""
+615
View File
@@ -0,0 +1,615 @@
"""
core/agent.py — TradingAgent (Phase 1: read-only Copilot)
==========================================================
Ein KI-Copilot, der den kompletten Systemzustand liest, in Klartext
beurteilt und eine begründete Empfehlung gibt — OHNE etwas auszuführen.
Die schnellen, deterministischen Entscheidungen (Wellen-Signal, Trailing,
Emergency-Close) bleiben im Code; der Agent ist die langsame, denkende
Schicht darüber (Intervall ~5 min, nicht im Tick-Pfad).
Read-Tools (liefern vorhandene snapshot()-Methoden):
_tool_market → Wellen-Signal + TradersUnion-Tachos
_tool_position → offene Position + Live-P&L + Trailing-Phase
_tool_account → Symbol/Preis/Spread/Balance/Equity/RSI/ATR/Reversal
_tool_performance → Tages-/Wochen-Statistik + letzte Trades + Dry-Run
_tool_news → News-Sentiment
Phase 1 ruft die Tools deterministisch auf (ein LLM-Call pro Runde, schont
das Quota). Die saubere Tool-Trennung erlaubt in Phase 2 echtes
Function-Calling + Trade-Vorschläge.
Provider: lokales LLM via Ollama (Default — kein Key, kein Quota) oder Claude
(offizielles anthropic-SDK), Gemini/OpenAI als Fallback. Konfiguration in
oil_widget_config.ini ([ollama]/[anthropic]/[gemini]/[openai]).
"""
from __future__ import annotations
import json
import threading
import time
from core.logger import get_logger
from core.market_hours import session_state
log = get_logger("agent")
def _agent_session() -> dict:
"""Kompakter Session-Status für den Agent-Kontext."""
ss = session_state()
return {
"phase": ss["phase"],
"active": ss["active"],
"just_opened": ss["just_opened"],
"next_open": ss["next_open"],
}
_GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
_OPENAI_URL = "https://api.openai.com/v1/chat/completions"
_SYS = (
"Du bist ein nüchterner Trading-Copilot für WTI-Rohöl (Intraday-Scalping). "
"Du bekommst den aktuellen Systemzustand eines automatischen Handels-Bots "
"(Wellen-Signal als ATR-ZigZag, TradersUnion-Tachos, offene Position, "
"Konto, Track-Record, News). Beurteile die Lage knapp und ehrlich — keine "
"Garantien, keine Hype-Sprache. Wenn die Signale widersprüchlich oder dünn "
"sind, sag das klar und empfiehl NEUTRAL/abwarten.\n\n"
"WICHTIG — nutze den Track-Record zur Kalibrierung: 'current_setup_history' "
"zeigt, wie das aktuell anstehende Setup bisher real gelaufen ist, "
"'by_setup' die übrigen, 'week'/'today' die Gesamtbilanz. Hat das aktuelle "
"Setup eine schwache Trefferquote oder negative Durchschnitts-PnL (avg_pnl), "
"dämpfe deine Konfidenz deutlich oder empfiehl NEUTRAL — auch wenn das "
"Live-Signal stark wirkt. Hat es sich bewährt, darf das deine Konfidenz "
"stützen. Folge dem Signal nicht blind gegen eine klar negative Historie; "
"nenne den Bezug in 'reasoning' kurz (z.B. Setup-Trefferquote).\n\n"
"Wähle außerdem den Analyse-Timeframe 'timeframe' für die Wellen-Erkennung "
"(M1|M5|M15|M30|H1): M1/M5 bei enger Range und ruhigem Markt (Scalping, "
"kleine Wellen); höhere TF (M15/M30/H1) bei klarem Trend, hoher Volatilität "
"oder wenn die niedrige TF laut Track-Record zu verrauscht ist (viele "
"Fehlsignale). Nimm den Timeframe, auf dem die Welle am klarsten und "
"verlässlichsten handelbar ist. Im Zweifel M5.\n\n"
"Beziehe die Elliott-Wave-Struktur ('elliott') ein, falls vorhanden und "
"valid=true: Steht der Kurs nahe einem projizierten Wellen-5-Ziel "
"(target/1.618) oder ist 'exhaustion'=true bzw. der Impuls vollendet, ist "
"der Trend ERSCHÖPFT — sei vorsichtig mit Einstiegen in Trendrichtung und "
"rechne mit einem Reversal (spricht für NEUTRAL oder Gegenrichtung). Ein "
"offener FVG ('fvg') ist eine Reaktionszone (bullish=Support unter, "
"bearish=Widerstand über dem Kurs). Ist 'valid'=false oder die Struktur "
"unklar, ignoriere die Welle und entscheide nach dem Wellen-Signal. EW ist "
"Heuristik — nenne den Bezug in 'reasoning' nur, wenn er klar ist.\n\n"
"S/R-Level ('zones''resistances'/'supports' mit 'price' und 'dist'): das "
"sind die ECHTEN, aktuell berechneten M5-Pivot-Level (dieselben wie im Chart). "
"⚠ WICHTIG: Nenne in deiner Antwort AUSSCHLIESSLICH diese übergebenen Preise. "
"ERFINDE KEINE eigenen runden Marken (nicht '80 $'/'85 $', wenn sie nicht in "
"der Liste stehen) und runde die Level nicht. Kurs nahe einer Resistance "
"('dist' klein) → Abprall/Short möglich; nahe einem Support → Bounce/Long "
"möglich. Level sind Reaktionsbereiche, kein Selbstläufer.\n\n"
"Beachte die Börsen-Session ('session'): direkt nach einem Open "
"('just_opened' gesetzt, Frankfurt 9:00 / US 15:00) ist der Markt volatil "
"und whipsaw-anfällig — sei vorsichtiger (Konfidenz eher senken). In aktiver "
"US-/DE-Session ('active') gibt es mehr Liquidität und klarere Trends; "
"außerhalb (dünn) ist Vorsicht angebracht.\n\n"
"Antworte AUSSCHLIESSLICH mit einem JSON-Objekt in genau dieser Form "
"(deutsche Texte):\n"
"{\n"
' "bias": "LONG" | "SHORT" | "NEUTRAL",\n'
' "confidence": <0-100>,\n'
' "timeframe": "M1" | "M5" | "M15" | "M30" | "H1",\n'
' "headline": "<ein prägnanter Satz>",\n'
' "reasoning": "<2-4 Sätze Begründung>",\n'
' "risks": ["<Risiko 1>", "<Risiko 2>"],\n'
' "position_note": "<Hinweis zur offenen Position, sonst leer>"\n'
"}\n"
"Kein Markdown, keine Code-Fences, nur das JSON.\n"
"SPRACHE: Alle Texte (headline, reasoning, risks, position_note) MÜSSEN auf "
"DEUTSCH sein. Verwende ausschließlich lateinische Buchstaben — KEINE "
"chinesischen, japanischen oder kyrillischen Zeichen, kein Englisch."
)
def _has_cjk(rec: dict) -> bool:
"""True, wenn die Text-Felder chinesische/CJK-Zeichen enthalten (Modell hat die
Deutsch-Vorgabe ignoriert — v.a. bei lokalen Qwen-Modellen)."""
txt = " ".join(str(rec.get(k, "")) for k in
("headline", "reasoning", "position_note"))
txt += " ".join(str(x) for x in (rec.get("risks") or []))
return any("" <= c <= "鿿" for c in txt)
# JSON-Schema für strukturierte Ausgabe (Claude: output_config.format erzwingt es)
_SCHEMA = {
"type": "object",
"properties": {
"bias": {"type": "string", "enum": ["LONG", "SHORT", "NEUTRAL"]},
"confidence": {"type": "integer"},
"timeframe": {"type": "string",
"enum": ["M1", "M5", "M15", "M30", "H1"]},
"headline": {"type": "string"},
"reasoning": {"type": "string"},
"risks": {"type": "array", "items": {"type": "string"}},
"position_note": {"type": "string"},
},
"required": ["bias", "confidence", "timeframe", "headline", "reasoning",
"risks", "position_note"],
"additionalProperties": False,
}
class TradingAgent:
def __init__(self, cfg, *, data, trader, trail, tu, wave, history,
news, elliott=None):
self.cfg = cfg
self.data = data
self.trader = trader
self.trail = trail
self.tu = tu
self.wave = wave
self.history = history
self.news = news
self.elliott = elliott
ac = cfg["agent"] if cfg.has_section("agent") else {}
self.provider = (ac.get("provider", "local") or "local").lower()
self._model_cfg = ac.get("model", "") or ""
self.interval_min = max(1, int(ac.get("refresh_min", "5") or 5))
self.auto_enabled = (ac.get("enabled", "true") or "true").lower() == "true"
self.tg_push = (ac.get("telegram", "false") or "false").lower() == "true"
self._lock = threading.Lock()
self._busy = False
self._last: dict | None = None # letzte Beurteilung (geparst)
self._error: str | None = None
self._ts: float | None = None
# ── Provider-Konfiguration ───────────────────────────────────────────────
def _key(self, section: str) -> str:
try:
return (self.cfg[section]["api_key"] or "").strip()
except Exception:
return ""
def _anthropic_key(self) -> str:
return self._key("anthropic")
def _gemini_key(self) -> str:
return self._key("gemini")
def _openai_key(self) -> str:
return self._key("openai")
def _local_model(self) -> str:
try:
return (self.cfg["ollama"]["model"] or "").strip()
except Exception:
return ""
def _active_provider(self) -> str | None:
"""Bevorzugt den konfigurierten Provider, fällt sonst der Reihe nach
auf einen verfügbaren zurück. 'local' (Ollama) gilt als verfügbar,
sobald ein Modellname gesetzt ist — Erreichbarkeit wird erst beim
Aufruf geprüft (kein Live-Probe im häufig aufgerufenen Pfad)."""
avail = {
"local": bool(self._local_model()),
"claude": self._anthropic_key().startswith("sk-ant"),
"gemini": self._gemini_key().startswith("AIza"),
"openai": self._openai_key().startswith("sk-"),
"zai": bool(self._key("zai")),
"kimi": self._key("kimi").startswith("sk-"),
"deepseek": self._key("deepseek").startswith("sk-"),
}
default = ["local", "claude", "gemini", "openai"]
orders = {
"local": ["local", "claude", "gemini", "openai"],
"claude": ["claude", "local", "gemini", "openai"],
"gemini": ["gemini", "openai", "claude", "local"],
"openai": ["openai", "gemini", "claude", "local"],
"zai": ["zai", "kimi", "deepseek", "local"],
"kimi": ["kimi", "deepseek", "zai", "local"],
"deepseek": ["deepseek", "kimi", "zai", "local"],
}
for p in orders.get(self.provider, default):
if avail[p]:
return p
return None
def is_configured(self) -> bool:
return self._active_provider() is not None
# ── Read-Tools (lesen vorhandene Snapshots) ──────────────────────────────
def _tool_market(self, wsig: dict | None = None) -> dict:
wsig = wsig if wsig is not None else self.wave.signal()
wsnap = self.wave.snapshot()
# TU entfernt (2026-07-08): war nach der TU-Entfernung aus Empfehlung/Verdict
# die letzte Hintertür — der Copilot ist eine Verdict-Stimme, TU floss so
# indirekt wieder ein (lagging, nicht backtestbar).
return {
"wave_signal": wsig.get("signal"),
"wave_confidence": wsig.get("conf_pct"),
"wave_setup": wsig.get("setup"),
"wave_tf": wsnap.get("tf"),
"wave_direction": wsnap.get("direction"),
"wave_move_atr": wsnap.get("move_atr"),
"wave_reasons": wsig.get("reasons", [])[:4],
"session": _agent_session(),
}
def _tool_position(self) -> dict:
ps = self.trader.snapshot()
if ps.get("ticket") is None:
return {"open": False}
s = self.data.snapshot()
live = None
if s.get("bid") and s.get("ask"):
live = self.trader.live_pnl(s["bid"], s["ask"])
ts = self.trail.snapshot()
return {
"open": True,
"direction": "LONG" if ps.get("order_type") == 0 else "SHORT",
"lots": round(ps.get("lots") or 0.0, 2),
"entry": round(ps.get("entry_price") or 0.0, 3),
"pnl": round((live if live is not None else ps.get("pnl") or 0.0), 2),
"trailing": ts.get("enabled"),
"trail_phase": ts.get("phase"),
}
def _tool_account(self) -> dict:
s = self.data.snapshot()
return {
"symbol": s.get("symbol"),
"bid": s.get("bid"), "ask": s.get("ask"),
"spread": s.get("spread"),
"change": s.get("change"), "pct": s.get("pct"),
"balance": s.get("balance"), "equity": s.get("equity"),
"rsi_m15": round(s["rsi_m15"], 1) if s.get("rsi_m15") else None,
"atr_m15": round(s["atr_m15"], 3) if s.get("atr_m15") else None,
"angles": {k: round(v) for k, v in (s.get("angles") or {}).items()},
"reversal": s.get("reversal"),
}
def _tool_performance(self, cur_setup: str = "") -> dict:
out: dict = {}
# cur_setup: aktuelles Wellen-Setup, um seine Historie hervorzuheben
for period in ("today", "week"):
try:
t = self.history.stats_overview(period)
out[period] = {"trades": t["n_trades"], "winrate": round(t["winrate"]),
"pnl": round(t["total_pnl"], 2),
"profit_factor": (round(t["profit_factor"], 2)
if t["profit_factor"] else None)}
except Exception:
pass
try:
last = self.history.last_closed_trades(5)
out["last_trades"] = [
{"dir": r["direction"], "pnl": round(r["pnl"] or 0, 2),
"by": r["closed_by"], "setup": r.get("setup")}
for r in last]
except Exception:
pass
try:
dry = self.history.intended_summary("today")
out["dry_run_today"] = {"n": dry["n"], "winrate": round(dry["winrate"])}
except Exception:
pass
# Setup-Historie: alle (gefiltert) + das aktuell anstehende Setup separat,
# damit das Modell seine Konfidenz an der echten Bilanz kalibrieren kann
try:
ss = self.history.setup_stats("all")
out["by_setup"] = [
{"setup": s["setup"], "n": s["n"], "winrate": round(s["winrate"]),
"avg_pnl": round(s["avg_pnl"], 2)}
for s in ss if s["n"] >= 3][:6]
match = next((s for s in ss if s["setup"] == cur_setup), None)
if match and cur_setup:
out["current_setup_history"] = {
"setup": cur_setup, "n": match["n"],
"winrate": round(match["winrate"]),
"avg_pnl": round(match["avg_pnl"], 2),
"total_pnl": round(match["total_pnl"], 2)}
elif cur_setup:
out["current_setup_history"] = {
"setup": cur_setup, "n": 0, "note": "noch keine Trades"}
except Exception:
pass
return out
def _tool_news(self) -> dict:
try:
ns = self.news.sentiment_snapshot()
return {"score": ns.get("score"), "label": ns.get("label"),
"drivers": ns.get("drivers", [])[:3]}
except Exception:
return {}
def _tool_elliott(self) -> dict:
"""Elliott-Wave-/FVG-Heuristik (plausibel, nicht sicher)."""
if not self.elliott:
return {}
s = self.elliott.snapshot()
if s.get("stale") or s.get("pattern") in (None, "unclear"):
return {"struktur": "keine klare Welle erkennbar"}
return {
"tf": s.get("tf"),
"pattern": s.get("pattern"),
"wave": s.get("wave"),
"valid": s.get("valid"),
"target": s.get("target"),
"target_label": s.get("target_label"),
"exhaustion": s.get("exhaustion"),
"invalidation": s.get("invalidation"),
"fvg": s.get("fvg"),
"note": s.get("note"),
}
def _tool_zones(self) -> dict:
"""ECHTE S/R-Level = geclusterte M5-Pivots (`wave.pb_levels`, dieselbe Quelle
wie Chart/Dashboard/Auto-Close) — NICHT mehr die stale [zones]-Config (2026-
07-15). Nächste ~3 Widerstände über / ~3 Unterstützungen unter dem Kurs."""
ws = self.wave.snapshot() or {}
lv = ws.get("pb_levels") or {}
atr = (ws.get("pb_feats") or {}).get("atr") or 0.2
cur = (self.data.snapshot() or {}).get("bid")
if not cur or not (lv.get("ph") or lv.get("pl")):
return {}
def cluster(vals):
out = []
for v in sorted(vals):
if out and v - out[-1][-1] <= 0.5 * atr:
out[-1].append(v)
else:
out.append([v])
return [round(sum(g) / len(g), 3) for g in out]
ph = cluster(lv.get("ph") or [])
pl = cluster(lv.get("pl") or [])
res = [{"price": p, "dist": round(p - cur, 3)} for p in ph if p > cur][:3]
sup = [{"price": p, "dist": round(cur - p, 3)}
for p in reversed(pl) if p < cur][:3]
return {"current_price": round(cur, 3),
"resistances": res, "supports": sup}
def _gather_context(self) -> dict:
try:
wsig = self.wave.signal()
except Exception:
wsig = {}
return {
"market": self._tool_market(wsig),
"elliott": self._tool_elliott(),
"zones": self._tool_zones(),
"position": self._tool_position(),
"account": self._tool_account(),
"performance": self._tool_performance(wsig.get("setup", "") or ""),
"news": self._tool_news(),
}
# ── LLM-Aufruf (Provider-Dispatch) ───────────────────────────────────────
def _call_gemini(self, prompt: str) -> str:
import requests
model = self._model_cfg or (self.cfg["gemini"].get("model")
or "gemini-2.0-flash")
resp = requests.post(
_GEMINI_URL.format(model=model),
headers={"x-goog-api-key": self._gemini_key(),
"Content-Type": "application/json"},
json={"contents": [{"role": "user",
"parts": [{"text": _SYS + "\n\n" + prompt}]}],
"generationConfig": {"temperature": 0.3, "maxOutputTokens": 700}},
timeout=60)
resp.raise_for_status()
data = resp.json()
return data["candidates"][0]["content"]["parts"][0]["text"]
def _call_openai(self, prompt: str) -> str:
import requests
model = self._model_cfg or "gpt-4o-mini"
resp = requests.post(
_OPENAI_URL,
headers={"Authorization": f"Bearer {self._openai_key()}",
"Content-Type": "application/json"},
json={"model": model,
"messages": [{"role": "system", "content": _SYS},
{"role": "user", "content": prompt}],
"temperature": 0.3, "max_tokens": 700},
timeout=60)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
def _call_zai(self, prompt: str) -> str:
# z.ai/GLM (OpenAI-kompatibel). Thinking AUS (sonst geht das Budget ins
# „Denken" und content bleibt leer). Web-Suche hier NICHT nötig (analysiert
# nur den Snapshot). CJK-Drift-Schutz via _has_cjk-Retry im Aufrufer.
import requests
zc = self.cfg["zai"] if self.cfg.has_section("zai") else {}
base = (zc.get("base_url") or "https://api.z.ai/api/paas/v4").rstrip("/")
model = self._model_cfg or (zc.get("model") or "glm-4.5-flash")
resp = requests.post(
base + "/chat/completions",
headers={"Authorization": "Bearer " + (zc.get("api_key") or "").strip()},
json={"model": model,
"messages": [{"role": "system", "content": _SYS},
{"role": "user", "content": prompt}],
"thinking": {"type": "disabled"},
"temperature": 0.3, "max_tokens": 900},
timeout=90)
resp.raise_for_status()
return (resp.json()["choices"][0]["message"].get("content") or "").strip()
def _call_kimi(self, prompt: str) -> str:
# Kimi / Moonshot AI (OpenAI-kompatibel, Endpoint .ai). kimi-k2.6 ist ein
# Reasoning-Modell: es schreibt VARIABLE „reasoning_tokens" (real 10001200
# beim echten _SYS) in message.reasoning_content VOR dem eigentlichen
# `content` → max_tokens muss großzügig sein, sonst frisst das Reasoning das
# Budget und content bleibt leer (gemessen: 2000 = teils leer, 4000 = ok).
# temperature MUSS 1 sein (Modell-Vorgabe, andere Werte → 400). CJK-Drift-
# Schutz via _has_cjk-Retry im Aufrufer (wie zai/local — Kimi ist CN-Modell).
import requests
kc = self.cfg["kimi"] if self.cfg.has_section("kimi") else {}
base = (kc.get("base_url") or "https://api.moonshot.ai/v1").rstrip("/")
model = self._model_cfg or (kc.get("model") or "kimi-k2.6")
resp = requests.post(
base + "/chat/completions",
headers={"Authorization": "Bearer " + (kc.get("api_key") or "").strip(),
"Content-Type": "application/json"},
json={"model": model,
"messages": [{"role": "system", "content": _SYS},
{"role": "user", "content": prompt}],
"temperature": 1, "max_tokens": 4000},
timeout=120)
resp.raise_for_status()
return (resp.json()["choices"][0]["message"].get("content") or "").strip()
def _call_deepseek(self, prompt: str) -> str:
# DeepSeek (OpenAI-kompatibel, api.deepseek.com). deepseek-v4-flash ist ein
# Reasoning-Modell (content nach reasoning_content) → max_tokens großzügig
# (4000), sonst content leer. temperature 0.3 ok. CJK-Drift-Schutz im Aufrufer.
import requests
dc = self.cfg["deepseek"] if self.cfg.has_section("deepseek") else {}
base = (dc.get("base_url") or "https://api.deepseek.com").rstrip("/")
model = self._model_cfg or (dc.get("model") or "deepseek-v4-flash")
resp = requests.post(
base + "/chat/completions",
headers={"Authorization": "Bearer " + (dc.get("api_key") or "").strip(),
"Content-Type": "application/json"},
json={"model": model,
"messages": [{"role": "system", "content": _SYS},
{"role": "user", "content": prompt}],
"temperature": 0.3, "max_tokens": 4000},
timeout=120)
resp.raise_for_status()
return (resp.json()["choices"][0]["message"].get("content") or "").strip()
def _call_ollama(self, prompt: str) -> str:
# Lokales LLM via Ollama (/api/chat). `format`=JSON-Schema erzwingt
# strukturierte Ausgabe. `keep_alive` hält das Modell zwischen den
# 5-Min-Ticks resident auf der GPU — sonst entlädt Ollama nach 5 min
# und lädt neu (Risiko: CPU-Rückfall bei knappem VRAM). Großzügiger
# Timeout, da CPU-Inferenz langsam ist.
import requests
oc = self.cfg["ollama"]
base = (oc.get("base_url") or "http://localhost:11434").rstrip("/")
model = self._model_cfg or (oc.get("model") or "qwen2.5:7b")
keep_alive = oc.get("keep_alive") or "30m"
resp = requests.post(
f"{base}/api/chat",
json={"model": model, "stream": False, "format": _SCHEMA,
"keep_alive": keep_alive,
"options": {"temperature": 0.3},
"messages": [{"role": "system", "content": _SYS},
{"role": "user", "content": prompt}]},
timeout=180)
resp.raise_for_status()
return resp.json()["message"]["content"]
def _call_claude(self, prompt: str) -> str:
# Offizielles anthropic-SDK. Adaptives Thinking (für die Abwägung der
# Signale) + erzwungenes JSON via output_config.format; effort=low, da
# es eine kurze Routine-Beurteilung alle paar Minuten ist.
import anthropic
model = self._model_cfg or (self.cfg["anthropic"].get("model")
or "claude-opus-4-8")
client = anthropic.Anthropic(api_key=self._anthropic_key())
resp = client.messages.create(
model=model,
max_tokens=4096,
system=_SYS,
thinking={"type": "adaptive"},
output_config={"effort": "low",
"format": {"type": "json_schema", "schema": _SCHEMA}},
messages=[{"role": "user", "content": prompt}],
)
if resp.stop_reason == "refusal":
raise RuntimeError("Claude-Refusal (Sicherheits-Klassifikator)")
# output_config.format garantiert: erster text-Block ist valides JSON
return next((b.text for b in resp.content if b.type == "text"), "")
@staticmethod
def _parse(text: str) -> dict:
"""Robustes JSON-Parsing (Code-Fences/Prosa drumherum tolerieren)."""
t = text.strip()
if "{" in t and "}" in t:
t = t[t.index("{"): t.rindex("}") + 1]
data = json.loads(t)
bias = str(data.get("bias", "NEUTRAL")).upper()
if bias not in ("LONG", "SHORT", "NEUTRAL"):
bias = "NEUTRAL"
tf = str(data.get("timeframe", "M5")).upper().strip()
if tf not in ("M1", "M5", "M15", "M30", "H1"):
tf = "M5"
return {
"bias": bias,
"confidence": int(data.get("confidence", 0) or 0),
"timeframe": tf,
"headline": str(data.get("headline", "")).strip(),
"reasoning": str(data.get("reasoning", "")).strip(),
"risks": [str(r) for r in (data.get("risks") or [])][:4],
"position_note": str(data.get("position_note", "")).strip(),
}
# ── Hauptlauf (blockierend — im Hintergrund-Thread aufrufen) ─────────────
def analyze(self) -> bool:
prov = self._active_provider()
if prov is None:
with self._lock:
self._error = ("Kein Provider (config.ini [ollama] model / "
"[anthropic] / [gemini] / [openai])")
return False
with self._lock:
if self._busy:
return False
self._busy = True
try:
ctx = self._gather_context()
prompt = ("Aktueller Systemzustand (JSON):\n"
+ json.dumps(ctx, ensure_ascii=False, indent=1)
+ "\n\nGib deine Beurteilung als JSON zurück. "
"Alle Texte auf DEUTSCH, nur lateinische Schrift "
"(keine chinesischen Zeichen).")
raw = (self._call_ollama(prompt) if prov == "local"
else self._call_zai(prompt) if prov == "zai"
else self._call_kimi(prompt) if prov == "kimi"
else self._call_deepseek(prompt) if prov == "deepseek"
else self._call_claude(prompt) if prov == "claude"
else self._call_openai(prompt) if prov == "openai"
else self._call_gemini(prompt))
parsed = self._parse(raw)
# CN-/lokale Modelle (Qwen, z.ai, Kimi, DeepSeek) driften gelegentlich ins
# Chinesische → einmal mit verschärfter Anweisung neu versuchen.
if prov in ("local", "zai", "kimi", "deepseek") and _has_cjk(parsed):
log.warning(f"[{prov}] CJK-Zeichen erkannt — Wiederholung auf Deutsch")
retry = (self._call_zai if prov == "zai"
else self._call_kimi if prov == "kimi"
else self._call_deepseek if prov == "deepseek"
else self._call_ollama)
raw = retry(prompt + "\n\nACHTUNG: Schreibe AUSSCHLIESSLICH auf "
"DEUTSCH, NUR lateinische Buchstaben, KEINE chinesischen "
"Zeichen.")
p2 = self._parse(raw)
if not _has_cjk(p2):
parsed = p2
with self._lock:
self._last = parsed
self._error = None
self._ts = time.time()
log.info(f"[{prov}] {parsed['bias']} ({parsed['confidence']}%) — "
f"{parsed['headline'][:80]}")
return True
except Exception as e:
with self._lock:
self._error = str(e)[:140]
log.warning(f"Agent-Analyse fehlgeschlagen ({prov}): {e}")
return False
finally:
with self._lock:
self._busy = False
def snapshot(self) -> dict:
with self._lock:
return {
"advisory": dict(self._last) if self._last else None,
"error": self._error,
"last_update": self._ts,
"busy": self._busy,
"provider": self._active_provider(),
"configured": self.is_configured(),
}
+343
View File
@@ -0,0 +1,343 @@
"""
core/ai.py — KI-Marktanalyse via OpenAI ChatGPT
=================================================
Nutzt `gpt-4o-mini-search-preview` (oder das größere `gpt-4o-search-preview`)
für eine Crude-Oil-Marktanalyse (WTI + Brent) mit Web-Suche.
Parsed eine strukturierte Antwort:
SENTIMENT: bullish|bearish|neutral
CONFIDENCE: 0-100
SUMMARY: <2-3 Sätze>
DRIVERS: <Treiber1> | <Treiber2> | <Treiber3>
Loggt jede Analyse in die History-DB (falls injiziert).
"""
from __future__ import annotations
import threading
import time
from core.logger import get_logger
log_ai = get_logger("ai")
log_hist = get_logger("hist")
class OpenAIAnalyzer:
"""
Ruft die OpenAI Chat-Completions API auf.
Web-Search ist bei *-search-preview-Modellen automatisch aktiv,
konfigurierbar über `search_context` (low|medium|high).
"""
PROMPT = (
"Du bist ein professioneller Rohstoff-Marktanalyst. Suche im Web nach den "
"wichtigsten Crude-Oil-News der letzten 24 Stunden — sowohl für "
"WTI (US Crude) als auch Brent (OPEC, US-Lagerbestände/EIA, "
"Geopolitik Naher Osten, Förderdaten, Nachfrage-Indikatoren, "
"Pipelines, Raffinerien).\n\n"
"Antworte AUSSCHLIESSLICH in genau diesem Format (deutsche Sprache):\n"
"SENTIMENT: bullish|bearish|neutral\n"
"CONFIDENCE: <0-100>\n"
"SUMMARY: <2-3 Sätze, prägnant, nur preisbewegende Faktoren>\n"
"DRIVERS: <Treiber1> | <Treiber2> | <Treiber3>\n\n"
"Sei knapp und konkret. Keine Disclaimer, keine Einleitung."
)
# Preis pro Mio Token (USD) grobe Schätzung für Cost-Anzeige
PRICES = {
"gpt-4o-mini-search-preview": (0.15, 0.60, 25.0),
"gpt-4o-search-preview": (2.50, 10.00, 30.0),
"_default": (0.15, 0.60, 25.0),
}
def __init__(self, api_key: str, model: str, search_context: str = "low"):
self.api_key = api_key.strip()
self.model = model.strip() or "gpt-4o-mini-search-preview"
self.search_context = search_context.strip().lower() or "low"
self.sentiment = None
self.confidence = None
self.summary = ""
self.drivers = []
self.last_update = None
self.last_cost = None
self.error = None
self.busy = False
self._lock = threading.Lock()
# History-Logger wird vom main() injiziert
self.history = None
def is_configured(self) -> bool:
return bool(self.api_key) and self.api_key.startswith(("sk-", "sk-proj-"))
def _estimate_cost(self, in_tok: int, out_tok: int) -> float:
in_p, out_p, search_p = self.PRICES.get(self.model, self.PRICES["_default"])
token_cost = (in_tok * in_p + out_tok * out_p) / 1_000_000
search_cost = search_p / 1000.0
return token_cost + search_cost
def analyze(self):
if not self.is_configured():
with self._lock:
self.error = "Kein API-Key (config.ini)"
return
try:
from openai import OpenAI
except ImportError:
with self._lock:
self.error = "pip install openai"
return
with self._lock:
if self.busy:
return
self.busy = True
self.error = None
try:
client = OpenAI(api_key=self.api_key)
kwargs = {
"model": self.model,
"messages": [{"role": "user", "content": self.PROMPT}],
}
if "search-preview" in self.model:
kwargs["web_search_options"] = {
"search_context_size": self.search_context,
}
else:
kwargs["max_tokens"] = 700
res = client.chat.completions.create(**kwargs)
text = (res.choices[0].message.content or "").strip()
# Strukturierte Antwort parsen
sentiment = "neutral"
confidence = 50
summary = text[:300]
drivers = []
for raw in text.split("\n"):
line = raw.strip()
low = line.lower()
if low.startswith("sentiment:"):
v = low.split(":", 1)[1].strip()
sentiment = ("bullish" if "bull" in v
else "bearish" if "bear" in v else "neutral")
elif low.startswith("confidence:"):
digits = "".join(c for c in line.split(":", 1)[1] if c.isdigit())[:3]
if digits:
confidence = max(0, min(100, int(digits)))
elif low.startswith("summary:"):
summary = line.split(":", 1)[1].strip()
elif low.startswith("drivers:"):
parts = line.split(":", 1)[1]
drivers = [d.strip() for d in parts.split("|") if d.strip()][:4]
# Token-Verbrauch & Kosten
usage = getattr(res, "usage", None)
cost_est = None
if usage:
in_tok = getattr(usage, "prompt_tokens", 0) or 0
out_tok = getattr(usage, "completion_tokens", 0) or 0
cost_est = self._estimate_cost(in_tok, out_tok)
with self._lock:
self.sentiment = sentiment
self.confidence = confidence
self.summary = summary
self.drivers = drivers
self.last_update = time.time()
self.last_cost = cost_est
self.error = None
log_ai.info(f"{sentiment.upper()} ({confidence}%) — {summary[:80]}")
if cost_est:
log_ai.info(f"Geschätzte Kosten: ${cost_est:.4f}")
if self.history:
try:
self.history.log_ai(
sentiment = sentiment,
confidence = confidence,
summary = summary,
drivers = drivers,
cost_estimate= cost_est,
model = self.model,
)
except Exception as e:
log_hist.error(f"log_ai: {e}")
except Exception as e:
err = str(e)
with self._lock:
self.error = err[:120]
log_ai.error(f"Fehler: {err}")
finally:
with self._lock:
self.busy = False
def snapshot(self):
with self._lock:
return {
"sentiment": self.sentiment,
"confidence": self.confidence,
"summary": self.summary,
"drivers": list(self.drivers),
"last_update": self.last_update,
"last_cost": self.last_cost,
"error": self.error,
"busy": self.busy,
"configured": self.is_configured(),
"model": self.model,
}
class GeminiAnalyzer:
"""
Ruft die Google Gemini API auf (REST, via requests).
Nutzt Google Search Grounding für aktuelle Web-Daten.
"""
PROMPT = (
"Du bist ein professioneller Rohstoff-Marktanalyst. Suche im Web nach den "
"wichtigsten Crude-Oil-News der letzten 24 Stunden — sowohl für "
"WTI (US Crude) als auch Brent (OPEC, US-Lagerbestände/EIA, "
"Geopolitik Naher Osten, Förderdaten, Nachfrage-Indikatoren, "
"Pipelines, Raffinerien).\n\n"
"Antworte AUSSCHLIESSLICH in genau diesem Format (deutsche Sprache):\n"
"SENTIMENT: bullish|bearish|neutral\n"
"CONFIDENCE: <0-100>\n"
"SUMMARY: <2-3 Sätze, prägnant, nur preisbewegende Faktoren>\n"
"DRIVERS: <Treiber1> | <Treiber2> | <Treiber3>\n\n"
"Sei knapp und konkret. Keine Disclaimer, keine Einleitung."
)
_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
def __init__(self, api_key: str, model: str = "gemini-2.0-flash"):
self.api_key = api_key.strip()
self.model = model.strip() or "gemini-2.0-flash"
self.sentiment = None
self.confidence = None
self.summary = ""
self.drivers = []
self.last_update = None
self.error = None
self.busy = False
self._lock = threading.Lock()
self.history = None
def is_configured(self) -> bool:
return bool(self.api_key) and self.api_key.startswith("AIza")
def analyze(self):
if not self.is_configured():
with self._lock:
self.error = "Kein Gemini API-Key (config.ini)"
return
try:
import requests as _req
except ImportError:
with self._lock:
self.error = "pip install requests"
return
with self._lock:
if self.busy:
return
self.busy = True
self.error = None
try:
url = f"{self._API_BASE}/{self.model}:generateContent"
payload = {
"contents": [{"role": "user", "parts": [{"text": self.PROMPT}]}],
"tools": [{"google_search": {}}],
"generationConfig": {
"temperature": 0.3,
"maxOutputTokens": 600,
},
}
# Key im Header statt als URL-Parameter — sonst landet er bei
# jedem HTTP-Fehler im Klartext in der Log-Fehlermeldung (URL)
resp = _req.post(
url,
headers={"x-goog-api-key": self.api_key},
json=payload,
timeout=60,
)
resp.raise_for_status()
result = resp.json()
text = ""
try:
text = result["candidates"][0]["content"]["parts"][0]["text"].strip()
except (KeyError, IndexError):
text = str(result)[:300]
sentiment = "neutral"
confidence = 50
summary = text[:300]
drivers = []
for raw in text.split("\n"):
line = raw.strip()
low = line.lower()
if low.startswith("sentiment:"):
v = low.split(":", 1)[1].strip()
sentiment = ("bullish" if "bull" in v
else "bearish" if "bear" in v else "neutral")
elif low.startswith("confidence:"):
digits = "".join(c for c in line.split(":", 1)[1] if c.isdigit())[:3]
if digits:
confidence = max(0, min(100, int(digits)))
elif low.startswith("summary:"):
summary = line.split(":", 1)[1].strip()
elif low.startswith("drivers:"):
parts = line.split(":", 1)[1]
drivers = [d.strip() for d in parts.split("|") if d.strip()][:4]
with self._lock:
self.sentiment = sentiment
self.confidence = confidence
self.summary = summary
self.drivers = drivers
self.last_update = time.time()
self.error = None
log_ai.info(f"[Gemini] {sentiment.upper()} ({confidence}%) — {summary[:80]}")
if self.history:
try:
self.history.log_ai(
sentiment = sentiment,
confidence = confidence,
summary = summary,
drivers = drivers,
cost_estimate= None,
model = self.model,
)
except Exception as e:
log_hist.error(f"[Gemini] log_ai: {e}")
except Exception as e:
err = str(e)
with self._lock:
self.error = err[:120]
log_ai.error(f"[Gemini] Fehler: {err}")
finally:
with self._lock:
self.busy = False
def snapshot(self):
with self._lock:
return {
"sentiment": self.sentiment,
"confidence": self.confidence,
"summary": self.summary,
"drivers": list(self.drivers),
"last_update": self.last_update,
"last_cost": None,
"error": self.error,
"busy": self.busy,
"configured": self.is_configured(),
"model": self.model,
}
+51
View File
@@ -0,0 +1,51 @@
"""
core/analysis/__init__.py
Re-exportiert alle öffentlichen Symbole für Rückwärtskompatibilität.
Alle bestehenden Imports wie `from core.analysis import calc_recommendation`
funktionieren unverändert weiter.
"""
from core.analysis.indicators import (
_ema,
calc_trend_angle,
FIB_RATIOS,
calc_rsi,
calc_atr,
calc_volume_ratio,
calc_fib_levels,
calc_fib_distance,
detect_regime,
calc_sr_distance,
calc_sma,
calc_vwap,
)
from core.analysis.ict import (
calc_bos,
calc_fvg,
calc_asia_levels,
calc_liquidity_sweep,
calc_order_block,
calc_ichimoku,
calc_coc,
)
from core.analysis.news import (
NEWS_BULLISH_KW,
NEWS_BEARISH_KW,
calc_news_sentiment,
)
from core.analysis.m15 import M15Analyzer, SRDetector
__all__ = [
"_ema", "calc_trend_angle", "FIB_RATIOS",
"calc_rsi", "calc_atr", "calc_volume_ratio",
"calc_fib_levels", "calc_fib_distance",
"detect_regime", "calc_sr_distance",
"calc_sma", "calc_vwap",
"calc_bos", "calc_fvg", "calc_asia_levels",
"calc_liquidity_sweep", "calc_order_block", "calc_ichimoku", "calc_coc",
"NEWS_BULLISH_KW", "NEWS_BEARISH_KW", "calc_news_sentiment",
"M15Analyzer", "SRDetector",
]
+348
View File
@@ -0,0 +1,348 @@
"""
core/analysis/ict.py — ICT / SMC Konzepte
BOS, FVG, Asia Levels, Liquidity Sweep, Order Block, Ichimoku
"""
from __future__ import annotations
def calc_bos(highs: list, lows: list, closes: list,
lookback: int = 30, pivot_win: int = 3) -> dict:
"""
Break of Structure (ICT/SMC).
Rückgabe: {'bos': 'bullish'|'bearish'|None, 'bos_level': float|None, 'bars_ago': int|None}
"""
n = len(closes)
if n < lookback + pivot_win + 3:
return {"bos": None, "bos_level": None, "bars_ago": None}
w = pivot_win
search_end = n - 1
last_swing_high = last_swing_low = None
for i in range(search_end - w - 1, max(w, search_end - lookback - 1), -1):
lo = max(0, i - w); hi_r = min(n - 1, i + w)
if last_swing_high is None and highs[i] == max(highs[lo : hi_r + 1]):
last_swing_high = highs[i]
if last_swing_low is None and lows[i] == min(lows[lo : hi_r + 1]):
last_swing_low = lows[i]
if last_swing_high is not None and last_swing_low is not None:
break
if last_swing_high is None or last_swing_low is None:
return {"bos": None, "bos_level": None, "bars_ago": None}
for ago in range(1, 6):
if n - ago - 1 < 1:
break
c_now = closes[n - ago]
c_prev = closes[n - ago - 1]
if c_now < last_swing_low <= c_prev:
return {"bos": "bearish", "bos_level": last_swing_low, "bars_ago": ago}
if c_now > last_swing_high >= c_prev:
return {"bos": "bullish", "bos_level": last_swing_high, "bars_ago": ago}
return {"bos": None, "bos_level": None, "bars_ago": None}
def calc_fvg(highs: list, lows: list, closes: list, lookback: int = 20) -> dict:
"""
Fair Value Gap / Imbalance (ICT-Definition).
Rückgabe: {'type': 'bullish'|'bearish'|None, 'top', 'bottom', 'mid', 'filled_pct', 'bars_ago'}
"""
n = len(closes)
if n < 4:
return {"type": None}
cur = closes[-1]
for i in range(n - 3, max(1, n - lookback - 2), -1):
if i + 2 >= n:
continue
h_before = highs[i - 1]; l_before = lows[i - 1]
h_after = highs[i + 1]; l_after = lows[i + 1]
if h_before < l_after:
bottom, top = h_before, l_after
if top <= bottom:
continue
filled_pct = max(0.0, min(100.0, (cur - bottom) / (top - bottom) * 100))
if filled_pct < 100.0:
return {"type": "bullish", "top": top, "bottom": bottom,
"mid": (top + bottom) / 2,
"filled_pct": round(filled_pct, 0), "bars_ago": n - 2 - i}
elif l_before > h_after:
bottom, top = h_after, l_before
if top <= bottom:
continue
filled_pct = max(0.0, min(100.0, (top - cur) / (top - bottom) * 100))
if filled_pct < 100.0:
return {"type": "bearish", "top": top, "bottom": bottom,
"mid": (top + bottom) / 2,
"filled_pct": round(filled_pct, 0), "bars_ago": n - 2 - i}
return {"type": None}
def calc_asia_levels(bars: list) -> dict | None:
"""
Asien-Session Hoch/Tief (00:0008:00 UTC) aus M15-Bars.
Rückgabe: {'high': float, 'low': float, 'n': int} oder None.
"""
if not bars:
return None
import time as _time
from datetime import datetime, timezone as _tz
now_ts = _time.time()
today_utc = datetime.fromtimestamp(now_ts, tz=_tz.utc).replace(
hour=0, minute=0, second=0, microsecond=0)
today_ts = today_utc.timestamp()
asia_end_ts = today_ts + 8 * 3600
asia_bars = [b for b in bars
if today_ts <= int(b["time"]) < asia_end_ts]
if not asia_bars:
return None
return {
"high": max(float(b["high"]) for b in asia_bars),
"low": min(float(b["low"]) for b in asia_bars),
"n": len(asia_bars),
}
def calc_liquidity_sweep(highs: list, lows: list, closes: list, opens: list,
lookback: int = 25, pivot_win: int = 3) -> dict:
"""
Liquidity Sweep (ICT): Wick über Swing-High/-Low, Schluss zurück.
Rückgabe: {'sweep': 'bearish'|'bullish'|None, 'level': float|None, 'bars_ago': int|None}
"""
n = len(closes)
if n < lookback + pivot_win + 3:
return {"sweep": None, "level": None, "bars_ago": None}
w = pivot_win
search_end = n - 1
swing_highs = []
swing_lows = []
for i in range(max(w, search_end - lookback), search_end - w):
lo = max(0, i - w); hi_r = min(n - 1, i + w)
if highs[i] == max(highs[lo : hi_r + 1]):
swing_highs.append(highs[i])
if lows[i] == min(lows[lo : hi_r + 1]):
swing_lows.append(lows[i])
if not swing_highs or not swing_lows:
return {"sweep": None, "level": None, "bars_ago": None}
pivot_high = max(swing_highs)
pivot_low = min(swing_lows)
for ago in range(1, 4):
idx = n - ago - 1
if idx < 1:
break
h = highs[idx]; l = lows[idx]; c = closes[idx]
if h > pivot_high and c < pivot_high:
return {"sweep": "bearish", "level": pivot_high, "bars_ago": ago}
if l < pivot_low and c > pivot_low:
return {"sweep": "bullish", "level": pivot_low, "bars_ago": ago}
return {"sweep": None, "level": None, "bars_ago": None}
def calc_order_block(highs: list, lows: list, closes: list, opens: list,
lookback: int = 40, min_impulse_bars: int = 3,
atr: float | None = None) -> dict:
"""
Order Block (ICT/SMC).
Bullish OB: letzter Bear-Candle vor starkem Aufwärts-Impuls → Support-Zone
Bearish OB: letzter Bull-Candle vor starkem Abwärts-Impuls → Resistance-Zone
Rückgabe: {'type': 'bullish'|'bearish'|None, 'high', 'low', 'mid', 'bars_ago', 'mitigated'}
"""
n = len(closes)
if n < lookback + min_impulse_bars + 2:
return {"type": None}
atr_eff = atr if atr and atr > 0 else 0.5
min_move = 1.5 * atr_eff
for end in range(n - min_impulse_bars - 1, max(1, n - lookback - 1), -1):
if end + min_impulse_bars >= n:
continue
bull_move = closes[end + min_impulse_bars] - closes[end]
bear_move = closes[end] - closes[end + min_impulse_bars]
if bull_move > min_move:
for ob_i in range(end, max(0, end - 6), -1):
if closes[ob_i] < opens[ob_i]:
ob_h = highs[ob_i]; ob_l = lows[ob_i]
mit = any(lows[j] < ob_h and highs[j] > ob_l
for j in range(ob_i + 1, n))
return {"type": "bullish", "high": ob_h, "low": ob_l,
"mid": (ob_h + ob_l) / 2,
"bars_ago": n - 1 - ob_i, "mitigated": mit}
elif bear_move > min_move:
for ob_i in range(end, max(0, end - 6), -1):
if closes[ob_i] > opens[ob_i]:
ob_h = highs[ob_i]; ob_l = lows[ob_i]
mit = any(highs[j] > ob_l and lows[j] < ob_h
for j in range(ob_i + 1, n))
return {"type": "bearish", "high": ob_h, "low": ob_l,
"mid": (ob_h + ob_l) / 2,
"bars_ago": n - 1 - ob_i, "mitigated": mit}
return {"type": None}
def calc_coc(highs: list, lows: list, closes: list,
lookback: int = 50, pivot_win: int = 3) -> dict:
"""
Change of Character (CoC / CHOCH) — ICT/SMC Trendumkehrsignal.
Algorithmus:
1. Finde das jüngste Swing-High UND das jüngste Swing-Low im Lookback.
2. Welches Extrem ist jünger bestimmt den vorherigen Bias:
• SH jünger → Uptrend → suche das letzte Swing-Low VOR dem SH (= Higher Low)
Wenn Close unter dieses HL bricht → bearischer CoC
• SL jünger → Downtrend → suche das letzte Swing-High VOR dem SL (= Lower High)
Wenn Close über dieses LH bricht → bullischer CoC
Unterschied zu BOS:
BOS = Strukturbruch IN Trendrichtung (Fortsetzung)
CoC = Strukturbruch GEGEN den Trend (Umkehrsignal, stärker)
Rückgabe:
coc: 'bearish' | 'bullish' | None
coc_level: gebrochenes Strukturniveau (Higher Low / Lower High)
swing_extreme: letztes Swing-Extrem (SH/SL = der Pivot der den Trend definierte)
bars_ago: Bars seit dem Bruch
"""
n = len(closes)
if n < pivot_win * 2 + 12:
return {"coc": None, "coc_level": None, "swing_extreme": None, "bars_ago": None}
w = pivot_win
lb = min(lookback, n - w - 2)
def _find_pivot(seq_high: bool, start: int, stop: int) -> tuple[int, float] | None:
for i in range(start, max(w, stop), -1):
lo = max(0, i - w); hi_r = min(n - 1, i + w)
if seq_high and highs[i] == max(highs[lo:hi_r + 1]):
return (i, highs[i])
if not seq_high and lows[i] == min(lows[lo:hi_r + 1]):
return (i, lows[i])
return None
# ── Jüngstes Swing-High und Swing-Low im Lookback ────────────────────────
recent_sh = _find_pivot(True, n - 1 - w, n - lb - 1)
recent_sl = _find_pivot(False, n - 1 - w, n - lb - 1)
if recent_sh is None or recent_sl is None:
return {"coc": None, "coc_level": None, "swing_extreme": None, "bars_ago": None}
sh_idx, sh_price = recent_sh
sl_idx, sl_price = recent_sl
lb_stop = max(w, n - lb - 1) # ältestes Bar das in Lookback fällt
# ── Bearish CoC: letztes Extrem war ein Swing-High ────────────────────────
if sh_idx > sl_idx:
# Suche den Swing-Low VOR dem SH (= der Higher Low im Uptrend)
# Suchbereich: komplett rückwärts bis Ende des Lookback-Fensters
hl = _find_pivot(False, sh_idx - w - 1, lb_stop)
if hl is None:
return {"coc": None, "coc_level": None, "swing_extreme": None, "bars_ago": None}
hl_price = hl[1]
for ago in range(1, 10):
if n - ago - 1 < 1:
break
c_now = closes[n - ago]
c_prev = closes[n - ago - 1]
if c_now < hl_price <= c_prev:
return {"coc": "bearish", "coc_level": round(hl_price, 5),
"swing_extreme": round(sh_price, 5), "bars_ago": ago}
# ── Bullish CoC: letztes Extrem war ein Swing-Low ─────────────────────────
elif sl_idx > sh_idx:
# Suche den Swing-High VOR dem SL (= der Lower High im Downtrend)
lh = _find_pivot(True, sl_idx - w - 1, lb_stop)
if lh is None:
return {"coc": None, "coc_level": None, "swing_extreme": None, "bars_ago": None}
lh_price = lh[1]
for ago in range(1, 10):
if n - ago - 1 < 1:
break
c_now = closes[n - ago]
c_prev = closes[n - ago - 1]
if c_now > lh_price >= c_prev:
return {"coc": "bullish", "coc_level": round(lh_price, 5),
"swing_extreme": round(sl_price, 5), "bars_ago": ago}
return {"coc": None, "coc_level": None, "swing_extreme": None, "bars_ago": None}
def calc_ichimoku(highs: list, lows: list, closes: list,
tenkan: int = 9, kijun: int = 26, senkou_b: int = 52) -> dict | None:
"""
Ichimoku Kinko Hyo — Wolken-Analyse (Standard 9/26/52).
ichi_bias: 4=strong_bull, 3=bull, 2=neutral, 1=bear, 0=strong_bear
"""
n = len(closes)
if n < senkou_b + kijun + 1:
return None
def midpoint(h_sl, l_sl):
return (max(h_sl) + min(l_sl)) / 2
tenkan_val = midpoint(highs[-tenkan:], lows[-tenkan:])
kijun_val = midpoint(highs[-kijun:], lows[-kijun:])
off = kijun
if n - off - 1 < senkou_b:
return None
idx = n - off - 1
t_ago = midpoint(highs[idx - tenkan + 1: idx + 1], lows[idx - tenkan + 1: idx + 1])
k_ago = midpoint(highs[idx - kijun + 1: idx + 1], lows[idx - kijun + 1: idx + 1])
a_val = (t_ago + k_ago) / 2
b_val = midpoint(highs[idx - senkou_b + 1: idx + 1], lows[idx - senkou_b + 1: idx + 1])
cloud_top = max(a_val, b_val)
cloud_bot = min(a_val, b_val)
cur = closes[-1]
price_vs_cloud = ("above" if cur > cloud_top else
"below" if cur < cloud_bot else "inside")
tk_signal = "bullish" if tenkan_val >= kijun_val else "bearish"
cloud_color = "green" if a_val >= b_val else "red"
chikou_signal = "neutral"
if n > kijun:
ref = closes[n - 1 - kijun]
chikou_signal = "bullish" if cur > ref else ("bearish" if cur < ref else "neutral")
bull_pts = (
(1 if price_vs_cloud == "above" else 0) +
(1 if tk_signal == "bullish" else 0) +
(1 if chikou_signal == "bullish" else 0) +
(1 if cloud_color == "green" else 0)
)
ichi_bias = {4: "strong_bull", 3: "bull", 1: "bear", 0: "strong_bear"}.get(bull_pts, "neutral")
return {
"tenkan": round(tenkan_val, 3),
"kijun": round(kijun_val, 3),
"senkou_a": round(a_val, 3),
"senkou_b": round(b_val, 3),
"cloud_top": round(cloud_top, 3),
"cloud_bot": round(cloud_bot, 3),
"cloud_color": cloud_color,
"price_vs_cloud": price_vs_cloud,
"tk_signal": tk_signal,
"chikou_signal": chikou_signal,
"ichi_bias": ichi_bias,
"bull_pts": bull_pts,
}
+252
View File
@@ -0,0 +1,252 @@
"""
core/analysis/indicators.py — Mathematische Indikatoren & Helper
"""
from __future__ import annotations
import math
from core.config import ANGLE_LR_BARS
def _ema(values: list, period: int) -> list:
"""Exponentieller gleitender Durchschnitt."""
k = 2.0 / (period + 1)
out = [values[0]]
for v in values[1:]:
out.append(v * k + out[-1] * (1 - k))
return out
def calc_trend_angle(closes: list, n: int = ANGLE_LR_BARS) -> float:
"""
Lineare Regression über die letzten n Schlusskurse.
Liefert 0°–180°: 0° = starker Aufwärtstrend
90° = seitwärts
180° = starker Abwärtstrend
"""
data = closes[-n:] if len(closes) >= n else closes
k = len(data)
if k < 2:
return 90.0
xm = (k - 1) / 2.0
ym = sum(data) / k
num = sum((i - xm) * (v - ym) for i, v in enumerate(data))
den = sum((i - xm) ** 2 for i in range(k))
if den == 0:
return 90.0
slope = num / den
slope_pct = slope / (ym or 1) * 100
angle_rad = math.atan(slope_pct * 18)
angle = 90.0 - math.degrees(angle_rad)
return max(0.0, min(180.0, angle))
FIB_RATIOS = (0.236, 0.382, 0.500, 0.618, 0.786)
def calc_rsi(closes: list, period: int = 14) -> float:
"""Wilder-RSI über die letzten `period` Schlusskurse. 0100."""
if len(closes) < period + 1:
return 50.0
gains, losses = 0.0, 0.0
for i in range(1, period + 1):
diff = closes[i] - closes[i - 1]
if diff >= 0:
gains += diff
else:
losses -= diff
avg_g = gains / period
avg_l = losses / period
for i in range(period + 1, len(closes)):
diff = closes[i] - closes[i - 1]
g = max(diff, 0.0)
l = max(-diff, 0.0)
avg_g = (avg_g * (period - 1) + g) / period
avg_l = (avg_l * (period - 1) + l) / period
if avg_l == 0:
return 100.0
rs = avg_g / avg_l
return 100.0 - 100.0 / (1.0 + rs)
def calc_atr(highs: list, lows: list, closes: list, period: int = 14) -> float | None:
"""Average True Range, Wilder-Smoothing. Liefert None bei zu wenig Daten."""
n = len(highs)
if n < period + 1 or n != len(lows) or n != len(closes):
return None
trs = []
for i in range(1, n):
tr = max(highs[i] - lows[i],
abs(highs[i] - closes[i - 1]),
abs(lows[i] - closes[i - 1]))
trs.append(tr)
if len(trs) < period:
return None
atr = sum(trs[:period]) / period
for tr in trs[period:]:
atr = (atr * (period - 1) + tr) / period
return atr
def calc_volume_ratio(bars: list, lookback: int = 20) -> float:
"""
Verhältnis des letzten Bar-Tick-Volumens zum Ø der vorherigen lookback Bars.
> 1.5 = überdurchschnittlich, < 0.7 = unterdurchschnittlich
"""
vols = []
for b in bars:
try:
vols.append(float(b["tick_volume"] or 0))
except Exception:
vols.append(0.0)
if len(vols) < 3:
return 1.0
window = vols[-(lookback + 1):-1]
avg = sum(window) / len(window) if window else 0.0
return round(vols[-1] / avg, 2) if avg > 0 else 1.0
def calc_fib_levels(highs: list, lows: list, lookback: int = 50) -> dict | None:
"""
Fibonacci-Retracement-Level aus dem größten Swing-High/Low im lookback-Fenster.
"""
n = len(highs)
if n < 10 or n != len(lows):
return None
start = max(0, n - lookback)
win_h = highs[start:]
win_l = lows[start:]
hi_rel = max(range(len(win_h)), key=lambda i: win_h[i])
lo_rel = min(range(len(win_l)), key=lambda i: win_l[i])
swing_high = win_h[hi_rel]
swing_low = win_l[lo_rel]
rng = swing_high - swing_low
if rng <= 0:
return None
up_levels = {round(r * 100, 1): round(swing_high - r * rng, 5) for r in FIB_RATIOS}
down_levels = {round(r * 100, 1): round(swing_low + r * rng, 5) for r in FIB_RATIOS}
hi_idx = start + hi_rel
lo_idx = start + lo_rel
return {
"swing_high": swing_high,
"swing_low": swing_low,
"high_idx": hi_idx,
"low_idx": lo_idx,
"range": rng,
"direction": "up" if hi_idx > lo_idx else "down",
"levels": {"up": up_levels, "down": down_levels},
}
def calc_fib_distance(price: float, fib: dict | None, direction: str = "up") -> dict:
"""Abstand des Preises zum nächsten Schlüssel-Fib-Level (38.2, 50.0, 61.8)."""
empty = {"nearest_ratio": None, "nearest_price": None, "dist_pct": None}
if not fib or not price:
return empty
levels = fib["levels"].get(direction, {})
key = {k: v for k, v in levels.items() if k in (38.2, 50.0, 61.8)}
if not key:
return empty
nearest_ratio, nearest_price = min(key.items(), key=lambda kv: abs(kv[1] - price))
rng = fib.get("range", 1)
dist_pct = abs(nearest_price - price) / rng * 100 if rng > 0 else None
return {
"nearest_ratio": nearest_ratio,
"nearest_price": nearest_price,
"dist_pct": round(dist_pct, 1) if dist_pct is not None else None,
}
def detect_regime(angles: dict) -> str:
"""
Klassifiziert den Markt-Zustand anhand der 4 TF-Winkel.
Rückgabewerte: 'trend_up', 'trend_down', 'range', 'transition'
"""
if not angles:
return "transition"
vals = [angles.get(tf, 90.0) for tf in ("M5", "M15", "M30", "H1")]
spread = max(vals) - min(vals)
if all(v < 80 for v in vals):
return "trend_up"
if all(v > 100 for v in vals):
return "trend_down"
if spread < 25 and all(75 <= v <= 105 for v in vals):
return "range"
return "transition"
def calc_sma(values: list, period: int = 50) -> float | None:
"""Simple Moving Average über die letzten `period` Werte."""
if len(values) < period:
return None
return sum(values[-period:]) / period
def calc_vwap(bars: list) -> dict | None:
"""
Daily VWAP (Volume Weighted Average Price) aus Intraday-Bars.
Reset täglich um 00:00 UTC.
reclaim=True: Preis war in den letzten 3 Bars unter VWAP,
aktuelle Bar schloss darüber → VWAP-Reclaim-Signal.
Rückgabe: {'vwap', 'price_vs_vwap': 'above'|'below'|'at',
'reclaim': bool, 'n_bars': int, 'diff_pct': float}
"""
import time as _t
from datetime import datetime, timezone as _tz
if not bars:
return None
now_ts = _t.time()
today_utc = datetime.fromtimestamp(now_ts, tz=_tz.utc).replace(
hour=0, minute=0, second=0, microsecond=0)
today_ts = today_utc.timestamp()
today_bars = [b for b in bars if int(b["time"]) >= today_ts]
if len(today_bars) < 2:
return None
cum_tpv = 0.0
cum_vol = 0.0
vwap_series = []
for b in today_bars:
tp = (float(b["high"]) + float(b["low"]) + float(b["close"])) / 3
vol = float(b["tick_volume"] or 1)
cum_tpv += tp * vol
cum_vol += vol
vwap_series.append(cum_tpv / cum_vol if cum_vol > 0 else tp)
vwap = vwap_series[-1]
cur = float(today_bars[-1]["close"])
n = len(today_bars)
# Reclaim: letzte ≥2 Bars unter VWAP, jetzt drüber
prev_below = (n >= 3 and all(
float(today_bars[i]["close"]) < vwap_series[i]
for i in range(max(0, n - 3), n - 1)
))
reclaim = prev_below and (cur > vwap)
diff_pct = (cur - vwap) / vwap * 100 if vwap > 0 else 0.0
return {
"vwap": round(vwap, 3),
"price_vs_vwap": ("above" if cur > vwap * 1.0001 else
"below" if cur < vwap * 0.9999 else "at"),
"reclaim": reclaim,
"n_bars": n,
"diff_pct": round(diff_pct, 2),
}
def calc_sr_distance(price: float, sr: dict | None, atr: float | None) -> dict:
"""Distanz zum nächsten Support/Resistance in ATR-Einheiten."""
if not sr or not atr or atr <= 0 or not price:
return {"support_atr": None, "resistance_atr": None}
sups = sr.get("supports") or []
ress = sr.get("resistances") or []
s_dists = [price - s["price"] for s in sups if s.get("price") and s["price"] < price]
r_dists = [r["price"] - price for r in ress if r.get("price") and r["price"] > price]
return {
"support_atr": (min(s_dists) / atr) if s_dists else None,
"resistance_atr": (min(r_dists) / atr) if r_dists else None,
}
+257
View File
@@ -0,0 +1,257 @@
"""
core/analysis/m15.py — M15Analyzer und SRDetector
"""
from __future__ import annotations
import threading
import time
import MetaTrader5 as mt5
from core.config import (
M15_BARS, EMA_FAST, EMA_SLOW, PIVOT_WINDOW, ANGLE_LR_BARS,
SR_LOOKBACK, SR_PIVOT_WIN, SR_MIN_TOUCHES, SR_TOL_ATR_FACTOR,
SR_MAX_LINES, TL_MIN_PIVOTS, CHART_BARS,
)
from core.analysis.indicators import _ema
class M15Analyzer:
"""
Erkennt M15-Trendwenden anhand von 3 Indikatoren:
1. EMA(5)/EMA(13)-Crossover
2. Bullish/Bearish Engulfing
3. Frische Swing-Highs/Lows
Mindestens 2 Indikatoren müssen in dieselbe Richtung zeigen.
"""
def __init__(self, sym):
self.symbol = sym
self.result = None
self.reasons = []
self._lock = threading.Lock()
def analyze(self):
bars = mt5.copy_rates_from_pos(self.symbol, mt5.TIMEFRAME_M15, 0, M15_BARS)
if bars is None or len(bars) < max(EMA_SLOW + 2, PIVOT_WINDOW * 2 + 2):
return
closes = [float(b["close"]) for b in bars]
opens = [float(b["open"]) for b in bars]
highs = [float(b["high"]) for b in bars]
lows = [float(b["low"]) for b in bars]
n = len(bars); w = PIVOT_WINDOW; signals = []
ef = _ema(closes, EMA_FAST)
es = _ema(closes, EMA_SLOW)
if ef[-2] < es[-2] and ef[-1] > es[-1]:
signals.append(("bullish", f"EMA{EMA_FAST}/{EMA_SLOW}"))
if ef[-2] > es[-2] and ef[-1] < es[-1]:
signals.append(("bearish", f"EMA{EMA_FAST}/{EMA_SLOW}"))
if (closes[-2] < opens[-2] and closes[-1] > opens[-1]
and closes[-1] > opens[-2] and opens[-1] < closes[-2]):
signals.append(("bullish", "Bullish Engulfing"))
if (closes[-2] > opens[-2] and closes[-1] < opens[-1]
and closes[-1] < opens[-2] and opens[-1] > closes[-2]):
signals.append(("bearish", "Bearish Engulfing"))
for i in range(n - w - 2, n - 1):
h = highs[i]
if (all(h > highs[j] for j in range(max(0, i - w), i)) and
all(h > highs[j] for j in range(i + 1, min(n, i + w + 1)))):
signals.append(("bearish", f"Swing-High (Bar -{n - 1 - i})"))
break
for i in range(n - w - 2, n - 1):
l = lows[i]
if (all(l < lows[j] for j in range(max(0, i - w), i)) and
all(l < lows[j] for j in range(i + 1, min(n, i + w + 1)))):
signals.append(("bullish", f"Swing-Low (Bar -{n - 1 - i})"))
break
bull = [r for d, r in signals if d == "bullish"]
bear = [r for d, r in signals if d == "bearish"]
direction = None; reasons = []
if len(bull) >= 2:
direction = "bullish"; reasons = bull
elif len(bear) >= 2:
direction = "bearish"; reasons = bear
with self._lock:
self.result = direction
self.reasons = reasons
def snapshot(self):
with self._lock:
return self.result, list(self.reasons)
class SRDetector:
"""
Findet horizontale Support-/Resistance-Zonen und Trendlinien auf M15.
Algorithmus:
1. Swing-Pivots erkennen (lokale Hochs/Tiefs mit Fenster ±SR_PIVOT_WIN)
2. Pivots clustern: Preise innerhalb 0.5×ATR werden zu einer Zone
3. Zonen mit >= SR_MIN_TOUCHES Berührungen → gültiges S/R-Level
4. Trendlinien: lineare Regression durch jüngste Pivot-Lows/Highs
"""
SR_DETECT_INTERVAL_S = 60
def __init__(self, symbol: str):
self.symbol = symbol
self.supports = []
self.resistances = []
self.trendline_up = None
self.trendline_dn = None
self._lock = threading.Lock()
self._last_detect_ts: float = 0
@staticmethod
def _find_pivots(highs, lows, win):
n = len(highs)
ph, pl = [], []
for i in range(win, n - win):
h = highs[i]; l = lows[i]
if (all(h >= highs[j] for j in range(i - win, i)) and
all(h >= highs[j] for j in range(i + 1, i + win + 1))):
ph.append((i, h))
if (all(l <= lows[j] for j in range(i - win, i)) and
all(l <= lows[j] for j in range(i + 1, i + win + 1))):
pl.append((i, l))
return ph, pl
@staticmethod
def _atr(highs, lows, closes, period=14):
if len(highs) < period + 1:
return None
trs = []
for i in range(1, len(highs)):
tr = max(highs[i] - lows[i],
abs(highs[i] - closes[i - 1]),
abs(lows[i] - closes[i - 1]))
trs.append(tr)
return sum(trs[-period:]) / period
@staticmethod
def _cluster(pivots, tolerance):
if not pivots:
return []
prices = sorted(p[1] for p in pivots)
clusters = []
current = [prices[0]]
for p in prices[1:]:
if abs(p - current[-1]) <= tolerance:
current.append(p)
else:
clusters.append(current)
current = [p]
clusters.append(current)
return [(sum(c) / len(c), len(c)) for c in clusters]
@staticmethod
def _trendline(pivots_recent):
if len(pivots_recent) < TL_MIN_PIVOTS:
return None
xs = [p[0] for p in pivots_recent]
ys = [p[1] for p in pivots_recent]
n = len(xs)
xm = sum(xs) / n
ym = sum(ys) / n
num = sum((xs[i] - xm) * (ys[i] - ym) for i in range(n))
den = sum((xs[i] - xm) ** 2 for i in range(n))
if den == 0:
return None
slope = num / den
intercept = ym - slope * xm
x0 = xs[0]; x1 = xs[-1]
return {
"start_idx": x0,
"end_idx": x1,
"start_price": slope * x0 + intercept,
"end_price": slope * x1 + intercept,
"slope": slope,
}
def detect(self):
now = time.monotonic()
if now - self._last_detect_ts < self.SR_DETECT_INTERVAL_S:
return
self._last_detect_ts = now
bars = mt5.copy_rates_from_pos(
self.symbol, mt5.TIMEFRAME_M15, 0, SR_LOOKBACK)
if bars is None or len(bars) < 2 * SR_PIVOT_WIN + 5:
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 = self._atr(highs, lows, closes)
tol = (atr or (max(highs) - min(lows)) / 50) * SR_TOL_ATR_FACTOR
ph, pl = self._find_pivots(highs, lows, SR_PIVOT_WIN)
zones_high = [(p, n) for p, n in self._cluster(ph, tol)
if n >= SR_MIN_TOUCHES]
zones_low = [(p, n) for p, n in self._cluster(pl, tol)
if n >= SR_MIN_TOUCHES]
cur = closes[-1]
resistances = sorted(
[{"price": p, "touches": n} for p, n in zones_high if p > cur],
key=lambda z: z["price"])[:SR_MAX_LINES]
supports = sorted(
[{"price": p, "touches": n} for p, n in zones_low if p < cur],
key=lambda z: -z["price"])[:SR_MAX_LINES]
recent_cutoff = len(bars) - 50
recent_lows = [p for p in pl if p[0] >= recent_cutoff]
recent_highs = [p for p in ph if p[0] >= recent_cutoff]
tl_up = self._trendline(recent_lows[-3:]) if len(recent_lows) >= 2 else None
tl_dn = self._trendline(recent_highs[-3:]) if len(recent_highs) >= 2 else None
if tl_up and tl_up["slope"] <= 0:
tl_up = None
if tl_dn and tl_dn["slope"] >= 0:
tl_dn = None
n_bars = len(bars)
offset = n_bars - CHART_BARS
def _remap(tl):
if tl is None:
return None
si = tl["start_idx"] - offset
ei = tl["end_idx"] - offset
if tl["slope"] is not None and ei < CHART_BARS - 1:
extra = (CHART_BARS - 1) - tl["end_idx"]
ep = tl["end_price"] + tl["slope"] * extra
ei = CHART_BARS - 1
else:
ep = tl["end_price"]
if si < 0:
sp = tl["start_price"] + tl["slope"] * (offset - tl["start_idx"])
si = 0
else:
sp = tl["start_price"]
return {"start_idx": si, "end_idx": ei,
"start_price": sp, "end_price": ep}
with self._lock:
self.supports = supports
self.resistances = resistances
self.trendline_up = _remap(tl_up)
self.trendline_dn = _remap(tl_dn)
def snapshot(self):
with self._lock:
return {
"supports": list(self.supports),
"resistances": list(self.resistances),
"trendline_up": dict(self.trendline_up) if self.trendline_up else None,
"trendline_dn": dict(self.trendline_dn) if self.trendline_dn else None,
}
+76
View File
@@ -0,0 +1,76 @@
"""
core/analysis/news.py — Keyword-basiertes News-Sentiment
"""
from __future__ import annotations
# Bullische Phrasen für WTI (Angebot ↓ / Nachfrage ↑ / Risiko ↑)
NEWS_BULLISH_KW = {
"supply cut", "production cut", "opec cut", "opec+ cut", "output cut",
"sanction", "embargo", "ban on", "blockade", "shutdown", "outage",
"disruption", "halt", "force majeure", "pipeline attack",
"attack", "strike on", "missile", "drone strike", "tension escalat",
"iran tension", "houthi", "red sea", "strait of hormuz", "war",
"conflict escalat", "retaliat", "threat",
"demand growth", "demand surge", "demand rise", "demand strong",
"stockpile draw", "inventory draw", "stocks drop", "stocks fall",
"stockpiles fall", "crude draw", "cushing draw", "eia draw",
"pipeline shutdown", "refinery fire", "gulf of mexico storm",
"hurricane", "winter storm", "cold snap",
"oil surge", "oil rally", "oil jump", "oil soar", "oil spike",
"crude rise", "crude rally", "wti rise", "wti surge", "wti rally",
}
# Bärische Phrasen für WTI (Angebot ↑ / Nachfrage ↓ / Entspannung)
NEWS_BEARISH_KW = {
"supply glut", "oversupply", "production increase", "output rise",
"production boost", "opec boost", "opec+ unwind", "spr release",
"strategic reserve release", "saudi increase",
"us production record", "shale boom", "permian growth",
"demand drop", "demand fall", "demand weak", "demand slump",
"recession", "slowdown", "weak economy", "china slowdown",
"stockpile build", "inventory build", "stocks rise", "stocks build",
"crude build", "stockpiles rise", "cushing build", "eia build",
"ceasefire", "truce", "deal reached", "agreement", "diplomatic",
"talks resume", "easing tension", "sanction lift", "sanction relief",
"oil drop", "oil plunge", "oil slide", "oil fall", "oil decline",
"crude drop", "crude plunge", "wti fall", "wti slide", "oil crash",
}
def calc_news_sentiment(headlines: list, half_life_hours: float = 8.0) -> dict:
"""
Bewertet Headlines per Keyword-Matching mit altersgewichtetem Decay.
score: -1.0 (klar bärisch) … +1.0 (klar bullisch)
"""
import time as _time
if not headlines:
return {"score": 0.0, "n_bull": 0.0, "n_bear": 0.0, "samples": []}
now = _time.time()
bull_w = bear_w = 0.0
samples = []
for h in headlines:
text = (h.get("title_original") or h.get("title") or "").lower()
if not text:
continue
age_h = max(0.0, (now - h.get("ts", now)) / 3600.0)
weight = 0.5 ** (age_h / max(1.0, half_life_hours))
b = sum(1 for kw in NEWS_BULLISH_KW if kw in text)
s = sum(1 for kw in NEWS_BEARISH_KW if kw in text)
if b > s:
bull_w += weight * (1 + 0.3 * (b - 1))
samples.append(("bull", h.get("title", "")[:70]))
elif s > b:
bear_w += weight * (1 + 0.3 * (s - 1))
samples.append(("bear", h.get("title", "")[:70]))
total = bull_w + bear_w
score = 0.0 if total < 0.1 else (bull_w - bear_w) / total
return {
"score": max(-1.0, min(1.0, score)),
"n_bull": round(bull_w, 1),
"n_bear": round(bear_w, 1),
"samples": samples[:5],
}
+100
View File
@@ -0,0 +1,100 @@
"""
core/candle_logger.py — M1-Candle-Logger (Daten-Sammlung, KEIN Strategie-Eingriff)
==================================================================================
Schreibt **abgeschlossene** M1-Kerzen (OHLC + Spread + Tick-Volumen) in eine eigene
SQLite-Tabelle, damit über die Zeit ein tiefer M1-Datensatz entsteht — die
Broker-M1-History ist kurz und rollt weg. M1 ist die Master-Auflösung: daraus lässt
sich jede höhere TF (M5/M15/M30/H1) exakt aggregieren.
Zeitbasis: `time` = ROHE MT5-Bar-Zeit (**Broker/UTC+3**), wie `copy_rates_from_pos`
sie liefert — deckungsgleich mit allen Backtests. Nur ABGESCHLOSSENE Bars (die letzte,
noch offene Kerze wird nie geschrieben). Idempotent via PRIMARY KEY + INSERT OR IGNORE,
self-healing (jeder Fetch backfillt die letzten N Bars).
"""
from __future__ import annotations
import sqlite3
import threading
import time
import MetaTrader5 as mt5
from core.mt5_utils import mt5_lock
from core.logger import get_logger
log = get_logger("candles")
_REFRESH_S = 55.0 # M1 ändert sich je 60 s → ~55-s-Takt (kein Sub-Minuten-Fetch)
_FETCH_N = 180 # je Fetch die letzten N M1-Bars (backfillt Lücken bis ~3 h)
_STARTUP_N = 3000 # erster Lauf: tiefer holen (~2 Handelstage Backfill nach Restart)
class CandleLogger:
def __init__(self, db_path: str):
self.db_path = db_path
self._last = 0.0
self._first = True
self._lock = threading.Lock()
self._ensure()
def _ensure(self):
try:
con = sqlite3.connect(self.db_path, timeout=5.0)
con.execute("""CREATE TABLE IF NOT EXISTS candles_m1 (
time INTEGER PRIMARY KEY, -- Broker-Epoch (UTC+3), rohe MT5-Bar-Zeit
o REAL, h REAL, l REAL, c REAL,
spread REAL, -- in Preis (spread_points × point)
tick_volume INTEGER,
symbol TEXT )""")
con.commit(); con.close()
except Exception as e:
log.warning(f"candles_m1 anlegen: {e}")
def log(self, sym: str):
"""Im Trend-Loop aufgerufen; self-throttled auf ~55 s. Holt die letzten N
M1-Bars und schreibt die abgeschlossenen (INSERT OR IGNORE = dedupliziert)."""
if not sym:
return
now = time.time()
with self._lock:
if now - self._last < _REFRESH_S:
return
n = _STARTUP_N if self._first else _FETCH_N
try:
with mt5_lock(timeout=2) as got:
if not got:
return
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M1, 0, n)
si = mt5.symbol_info(sym); point = si.point if si else 0.01
if bars is None or len(bars) < 2:
return
# letzte Bar ist noch OFFEN → weglassen (nur abgeschlossene schreiben)
rows = [(int(b["time"]), float(b["open"]), float(b["high"]), float(b["low"]),
float(b["close"]), round(float(b["spread"]) * point, 5),
int(b["tick_volume"]), sym) for b in bars[:-1]]
con = sqlite3.connect(self.db_path, timeout=5.0)
before = con.execute("SELECT COUNT(*) FROM candles_m1").fetchone()[0]
con.executemany(
"INSERT OR IGNORE INTO candles_m1 (time,o,h,l,c,spread,tick_volume,symbol) "
"VALUES (?,?,?,?,?,?,?,?)", rows)
con.commit()
ins = con.execute("SELECT COUNT(*) FROM candles_m1").fetchone()[0] - before
con.close()
with self._lock:
self._last = now
was_first = self._first
self._first = False
if was_first:
log.info(f"M1-Candle-Logger: Start-Backfill +{ins} Bars ({sym})")
elif ins > 0:
log.debug(f"M1-Candles: +{ins}")
except Exception as e:
log.warning(f"candle log: {e}")
def stats(self) -> dict:
try:
con = sqlite3.connect(self.db_path, timeout=5.0)
r = con.execute("SELECT COUNT(*), MIN(time), MAX(time) FROM candles_m1").fetchone()
con.close()
return {"n": r[0] or 0, "first": r[1], "last": r[2]}
except Exception:
return {"n": 0}
+418
View File
@@ -0,0 +1,418 @@
"""
core/config.py — Globale Konstanten + Config-Loader
=====================================================
Zentrale Stelle für alle Magic-Numbers, Farben, Zeitintervalle
und das Laden der `oil_widget_config.ini`.
"""
from __future__ import annotations
import configparser
from pathlib import Path
import MetaTrader5 as mt5
from core.logger import get_logger
log = get_logger("config")
# ══════════════════════════════════════════════
# PFADE
# ══════════════════════════════════════════════
PROJECT_ROOT = Path(__file__).parent.parent
CONFIG_FILE = PROJECT_ROOT / "oil_widget_config.ini"
HISTORY_DB_FILE = PROJECT_ROOT / "oil_widget_history.db"
# ══════════════════════════════════════════════
# TRADING-KONSTANTEN
# ══════════════════════════════════════════════
SYMBOL_CANDIDATES = [
# WTI / USOIL — gehandeltes Instrument (SpotCrude). Reine WTI-Kandidatenliste.
# (Brent-Umstellung 2026-07-09 wurde am selben Tag zurückgenommen.)
"SpotCrude", "USOIL", "WTI", "XTIUSD", "WTICOUSD", "OIL.WTI", "CRUDE.WTI",
"USOilSpot", "SpotWTI", "WTISpot", "USCrude", "USOilCash",
]
MARGIN_BUFFER = 0.90 # Default; Laufzeitwert aus `[trading] margin_buffer_pct`
# (aktuell 90). Lot-Größe = ~90 % der freien Margin
# (User-Vorgabe) via `calc_lots`.
RISK_PER_TRADE = 0.015 # AKTIV: Verlust beim Initial-SL ≈ 1,5 % der Equity je
# Trade (`calc_lots_risk`). Laufzeitwert aus
# `[trading] risk_pct`. 0 = aus → Fallback margin-basiert
# (MARGIN_BUFFER). Behebt die großen EUR-Verluste
# (90 %-Margin × 2×ATR-SL) — `docs/risiko-management.md`.
DEVIATION = 30
MAGIC = 230002
def set_margin_buffer(pct: float) -> float:
"""Setzt den globalen Margin-Buffer zur Laufzeit (1-99 %)."""
global MARGIN_BUFFER
MARGIN_BUFFER = max(0.01, min(0.99, pct / 100.0))
log.info(f"Margin-Buffer geändert: {MARGIN_BUFFER*100:.0f}%")
return MARGIN_BUFFER
def get_margin_buffer() -> float:
"""Aktuellen Margin-Buffer abrufen (Module-Level globals sind Snapshot-frei)."""
return MARGIN_BUFFER
def set_risk_per_trade(pct: float) -> float:
"""Setzt das Risiko pro Trade zur Laufzeit (% der Equity). 0 = aus (Fallback
margin-basiert). Sinnvoll 0,53 %."""
global RISK_PER_TRADE
RISK_PER_TRADE = max(0.0, min(0.10, pct / 100.0))
log.info(f"Risiko/Trade geändert: {RISK_PER_TRADE*100:.2f}% "
f"({'risiko-basiert' if RISK_PER_TRADE > 0 else 'aus → margin-basiert'})")
return RISK_PER_TRADE
def get_risk_per_trade() -> float:
return RISK_PER_TRADE
# ══════════════════════════════════════════════
# STOP-LOSS / TAKE-PROFIT
# ══════════════════════════════════════════════
SL_TF = mt5.TIMEFRAME_M15
SL_LOOKBACK = 60
SL_WINDOW = 4
SL_BUFFER_TICKS = 5
INIT_SL_FALLBACK = 0.012 # 1.2 %
INIT_SL_MIN_ATR = 1.8 # Initial-SL-Distanz MIN 1.8×ATR(M15) — verhindert
# zu enge Stops, wenn der nächste Pivot direkt am
# Einstieg liegt (sonst Stop nach Sekunden auf Rauschen).
INIT_SL_MAX_ATR = 2.2 # Initial-SL-Distanz MAX 2.2×ATR(M15). Band [1.8 … 2.2]
# (Ziel 2,0): Exit-Simulation (backtest_exit.py) zeigte,
# dass 1,21,5 zu eng war (MAE ~1,7×ATR, 3137 % Früh-
# stopps); 2,0 hebt Ø-R +32 %, PF 1,33→1,39, Worst-Case
# auf 2,0×ATR begrenzt. Doku: docs/exit-simulation.md.
INIT_TP_RR = 2.0
# ══════════════════════════════════════════════
# ANALYSE
# ══════════════════════════════════════════════
M15_BARS = 60
EMA_FAST = 5
EMA_SLOW = 13
PIVOT_WINDOW = 3
ANGLE_LR_BARS = 14
SR_LOOKBACK = 150
SR_PIVOT_WIN = 4
SR_MIN_TOUCHES = 3
SR_TOL_ATR_FACTOR = 0.5
SR_MAX_LINES = 2
TL_MIN_PIVOTS = 2
# ══════════════════════════════════════════════
# ANALYSE-DATENFENSTER
# ══════════════════════════════════════════════
CHART_BARS = 50 # Bar-Fenster der M15-Analyse (Trendlinien-Mapping)
# ══════════════════════════════════════════════
# TIMINGS
# ══════════════════════════════════════════════
PRICE_REFRESH_MS = 500
TREND_REFRESH_MS = 5_000
POS_REFRESH_MS = 1_000
BLINK_INTERVAL_MS = 380
BLINK_COUNT_MAX = 3
AUTO_BTN_ON_BG = "#1f6feb" # Blau AUTO-Button aktiv
AUTO_BTN_ON_FG = "#ffffff"
AUTO_BTN_ON_ACT = "#3081f0"
AUTO_BTN_OFF_BG = "#1a1e24"
AUTO_BTN_OFF_FG = "#8b949e"
WIDGET_X = 20
WIDGET_Y = 55
# ══════════════════════════════════════════════
# FARBEN
# ══════════════════════════════════════════════
BG = "#0d1117"
BORDER_NEUTRAL = "#30363d"
BORDER_POS = "#1a4731"
BORDER_NEG = "#3d1a1a"
BORDER_BLINK = "#1f6feb"
BLINK_BG = "#0d1f3c"
ACCENT = "#e8a000"
UP_COLOR = "#3fb950"
DOWN_COLOR = "#f85149"
SPREAD_COLOR = "#58a6ff"
TEXT_DIM = "#8b949e"
TEXT_BRIGHT = "#e6edf3"
GRID_COLOR = "#161b22"
EMA_F_COLOR = "#e8a000"
EMA_S_COLOR = "#58a6ff"
# Buttons
BTN_LONG_BG = "#1a6e35"; BTN_LONG_FG = "#ffffff"; BTN_LONG_ACT = "#26a050"
BTN_SHORT_BG = "#8b1a1a"; BTN_SHORT_FG = "#ffffff"; BTN_SHORT_ACT = "#b02020"
BTN_CLOSE_BG = "#2d2000"; BTN_CLOSE_FG = "#e8a000"; BTN_CLOSE_ACT = "#4a3600"
BTN_DIS_BG = "#1a1e24"; BTN_DIS_FG = "#3a4049"
# ══════════════════════════════════════════════
# CONFIG-FILE LOADER
# ══════════════════════════════════════════════
DEFAULT_CONFIG = {
"openai": {
"api_key": "",
"model": "gpt-4o-mini-search-preview",
"auto_refresh": "true",
"refresh_min": "60",
"search_context": "low",
},
"gemini": {
# Key gehört NUR in die lokale oil_widget_config.ini, nie in den Code
"api_key": "",
"model": "gemini-2.0-flash",
"enabled": "true",
},
"anthropic": {
# Claude-Key (sk-ant-…) — nur in die lokale config.ini, nie in den Code
"api_key": "",
"model": "claude-opus-4-8",
},
"ollama": {
# Lokales LLM via Ollama (kein Key, kein Quota, läuft offline)
"base_url": "http://localhost:11434",
"model": "qwen2.5:7b",
# Modell zwischen den Ticks geladen halten (GPU-resident, ~4,7 GB VRAM).
# "0" entlädt sofort nach jedem Aufruf, "-1" hält unbegrenzt.
"keep_alive": "30m",
},
"agent": {
"enabled": "true", # KI-Copilot automatisch laufen lassen
"provider": "local", # local (Ollama) | claude | gemini | openai | zai | kimi
"model": "", # leer = Provider-Default
"refresh_min": "5", # Intervall der Lagebeurteilung
"telegram": "false", # Beurteilung zusätzlich per Telegram pushen
},
"kimi": {
# Kimi / Moonshot AI (OpenAI-kompatibel). Key gehört in die ini (Secret).
# Endpoint = international .ai (der .cn-Endpoint akzeptiert diesen Key NICHT).
# kimi-k2.6 = Reasoning-Modell → verbraucht ~400 reasoning_tokens VOR dem
# content; _call_kimi setzt max_tokens großzügig + temperature=1 (Pflicht).
"api_key": "",
"base_url": "https://api.moonshot.ai/v1",
"model": "kimi-k2.6",
},
"deepseek": {
# DeepSeek (OpenAI-kompatibel, api.deepseek.com). deepseek-v4-flash ist ein
# Reasoning-Modell (content nach reasoning_content → max_tokens großzügig).
# ⚠ KEINE Web-Suche (rein Chat) → NICHT für daily_levels geeignet, nur Copilot.
"api_key": "",
"base_url": "https://api.deepseek.com",
"model": "deepseek-v4-flash",
},
"web": {
# Mobile-Web-Backend (server.py). Lese-Endpoints (Snapshot/WebSocket)
# sind offen; Trade-Aktionen brauchen diesen Token im X-Auth-Token-Header.
# Leer = wird beim ersten Start automatisch generiert und geloggt.
"api_token": "",
"require_confirm": "true", # Bestätigungs-Dialog vor jeder Order im UI
# Token-Pflicht für Trade-Aktionen. "false" = kein Token nötig
# (nur sinnvoll, wenn der Zugang anderweitig abgesichert ist, z.B. WireGuard).
"require_token": "true",
},
"news": {
"enabled": "true",
"refresh_min": "10",
},
"translation": {
"enabled": "true",
"target_language": "de",
"provider": "google",
"show_original": "false",
},
"telegram": {
"enabled": "false",
"bot_token": "",
"chat_id": "",
},
"trading": {
"margin_buffer_pct": "90",
# Risiko-basiertes Sizing: Verlust beim Initial-SL ≈ risk_pct % der Equity.
# 0 = aus → margin-basiert (margin_buffer_pct). Behebt große EUR-Verluste.
"risk_pct": "1.5",
"last_symbol": "",
"atr_tf": "Auto",
# Quellensteuer auf Gewinn-Trades (Broker behält pro Gewinn ein).
# DE: Abgeltungsteuer 25% + Soli 5,5% = 26.375. 0 = keine WHT.
"wht_pct": "0",
# Wer wählt den Analyse-Timeframe der Empfehlung?
# heuristic = schnelle Volatilitäts-/Trend-Heuristik (jede Minute, kein LLM)
# agent = der KI-Agent (LLM) wählt
# M1/M5/M15/M30/H1 = fest
"tf_select": "heuristic",
# Band, aus dem die Heuristik die Basis-TF wählt. Daytrading = M1M15
# (M30/H1 bleiben nur Kontext: Filter/Konfluenz). tf_min = niedrigste,
# tf_max = höchste erlaubte TF.
"tf_min": "M5",
"tf_max": "H1",
# ATR-Breakout-Bestätigung: Signal erst handeln, wenn der Kurs k×ATR in
# Signalrichtung lief (gemessen ~2× Edge/Trade). 0 = aus (Sofort-Einstieg).
"breakout_k": "1.0",
# Fixwert-Notfall-Stop = aus (0). Der frühere 2%-Auto-Arm war ~0,76×ATR = 3×
# enger als der Squeeze-SL und schüttelte validierte Ausbrüche raus (real
# 2026-07-17: 48,94 €). Seit 2026-07-20 läuft stattdessen das %-GAP-NETZ
# (auto_emergency_pct=3.0, s. u.) — nur in Kombination mit risk_pct=1.5 sinnvoll.
"auto_emergency_loss": "0",
# Gewinn-mitnehmen automatisch beim Öffnen setzen (Kontowährung). 0 = aus
# (dann nur manuell im UI). Merkt den zuletzt gesetzten Wert.
"auto_takeprofit": "0",
# Automatischer S/R-Close (gemessen `backtest_srclose_prob.py`): schließt eine
# Position im PLUS selbstständig, wenn der Kurs am gegenüberliegenden Level ist
# und das kalibrierte P(Durchbruch) unter `sr_close_pbreak` liegt (Level prallt
# wahrscheinlich ab). true/false.
"auto_sr_close": "true",
# Schwelle der User-Regel „laufen lassen wenn P(Durchbruch) ≥ X, sonst close".
# 0.60 = validiertes Optimum-Plateau (0,500,60 ~gleich). Band [0.30 … 0.90].
"sr_close_pbreak": "0.60",
# S/R-Level als CSV nach MQL5\Files schreiben (für den SR_Levels.mq5-Indikator,
# der sie im Desktop-Terminal zeichnet). true/false.
"export_mql5_levels": "true",
# Mindestgewinn (Kontowährung) für den S/R-Auto-Close: Trade wird am Level
# nur geschlossen, wenn P&L ≥ diesem Betrag. Wird bei JEDER neuen Position
# automatisch neu gesetzt, s. `sr_close_min_gain_pct` (0 dort = aus, dann bleibt
# dieser Wert manuell/persistiert maßgeblich). ⚠ Gemessen ist ein Mindestgewinn
# tendenziell SCHLECHTER als ohne (`backtest_srclose_prob.py`, Mindestgewinn-
# Sektion: verliert monoton in beiden Hälften) — bewusster User-Opt-in.
"sr_close_min_gain": "0",
# Gewinn-Close automatisch auf X % des EINSATZES (Margin) der jeweiligen Position
# setzen, sobald sie eröffnet wird (User-Vorgabe 2026-07-23, 1%→3% am selben Tag
# nachgezogen zusammen mit dem margin-%-Notfall-Stop). 0 = aus (dann bleibt
# der manuell gesetzte/gemerkte `sr_close_min_gain` unverändert maßgeblich).
"sr_close_min_gain_pct": "3.0",
# Auto-Notfall-Stop als % der Balance beim Öffnen (skaliert mit dem Konto).
# >0 → Stop = pct% × Balance; 0 = aus. ⚠ Nur zusammen mit risk_pct>0 als
# GAP-NETZ sinnvoll (dann pct ≈ 2× risk_pct → feuert nur bei Slippage/Gap
# ÜBER den SL hinaus): bei Margin-Sizing ist ein %-Stop in hoher Vola enger
# als der 2×ATR-SL und zerschießt die Squeeze-Strategie (gemessen 2026-07-17,
# 48,94-€-Tag). 2026-07-20 kurz als 3%-Netz mit risk_pct=1.5 aktiv, mit der
# Rückkehr zu Margin-Sizing (80 %) am selben Tag wieder AUS. Bleibt 0 — ersetzt
# durch `auto_emergency_margin_pct` (s. u.), hat Vorrang wenn >0.
"auto_emergency_pct": "0",
# Notfall-Stop als % der EINSATZ-Margin der Position (User-Vorgabe 2026-07-23,
# analog zu `sr_close_min_gain_pct`). >0 → Stop = pct% × Margin dieser Position,
# hat Vorrang vor `auto_emergency_pct` (Balance) und dem Fixwert. 0 = aus.
# ⚠ Gleiche Kopplungs-Warnung wie beim Balance-%-Modus: unter Margin-Sizing kann
# ein enger %-Stop schneller greifen als der 2×ATR-SL — bewusste User-Vorgabe.
"auto_emergency_margin_pct": "3.0",
# Entry-Raum-Gate: Signal → WARTEN, wenn das Gegenlevel (M5-Pivot in Trade-
# Richtung) näher als X×ATR liegt (gemessen `backtest_entryroom.py` — Raum
# <0,6 in beiden Hälften negativ: Ertrag am Level gedeckelt, Kosten fressen
# den Rest). 0 = aus.
"entry_room_atr": "0.6",
# Tageszeit-Gate: Komma-Liste der Stunden (Berlin), zu denen die Wellen-
# Empfehlung WARTEN erzwingt. **Gemessener Default = 07,12,16** (Nacht-
# Kostenfalle + 12/16 Uhr beidhälftig negativ, `backtest_hourly_split.py`).
# LEER = Gate AUS. Die ini setzt es leer (User-Vorgabe 2026-07-22, bewusst
# gegen die Messung). EIA-Blackout (Mi 15:3016:30) ist separat, bleibt aktiv.
"dead_hours": "0,1,2,3,4,5,6,7,12,16",
# Autonomer Entry auf den Squeeze-Ausbruch (echte Order, nur flat). ⚠ Default
# AUS — Autonomie nie stillschweigend an. true = Bot eröffnet selbständig.
"auto_squeeze": "false",
# Startup-Schonfrist (Sek.): nach Bot-Start schließt der Bot so lange KEINEN
# laufenden Trade selbst (S/R-Close/Notfall/Time-Stop/Reverse) — verhindert
# den Sofort-Close eines adoptierten Trades auf noch-instabilen Daten direkt
# nach restart_server.bat. Broker-SL unberührt. 0 = aus. (User-Vorgabe 2026-07-20)
"startup_close_grace_s": "60",
# Stop-&-Reverse für den Auto-Squeeze = AUS (Default, User-Vorgabe 2026-07-17):
# NUR der flat-Entry ist gemessen-validiert (2 Halbjahre, ØR +0,14…+0,23). Der
# Reverse (Gegen-Trade bei Ausbruch sofort schließen + drehen) ist UNBELEGT,
# verdoppelt Kosten und dreht am Ausbruch-Extrem (real 2026-07-17 18:55: Short
# 29,64 geschlossen, dann LONG am Erschöpfungs-Top). true = Reverse an.
"auto_squeeze_reverse": "false",
# Dead-Hour-Guard für den Auto-Squeeze (keine Nacht-Entries 07 Uhr Berlin) =
# AUS (User-Vorgabe 2026-07-17). Das live-Verlust-Argument war emergency-getrieben
# und mit dem Notfall-Stop-Ausbau moot; der gemessene Nacht-Kosten-Befund
# (`backtest_realcosts.py`, Spread÷Nacht-ATR 0,320,50) bleibt gültig → per
# B4-Wochenmonitor prüfen, ob Nacht-Squeezes tragen. Code (`_SQUEEZE_NIGHT`,
# `_squeeze_skip_night`) bleibt dormant/reaktivierbar (true = wieder an).
"auto_squeeze_skip_night": "false",
},
# (Der frühere `[setups]`-Block — Per-Setup-Toggles für den Dry-Run-Auto-Trader —
# ist entfernt, 2026-07-23: verwaist seit dem Entfernen des Dry-Run-Traders, wurde
# aber auch davor nirgends im Code gelesen. Die `[setups]`-Sektion kann in einer
# bestehenden oil_widget_config.ini stehen bleiben, wird nur nicht mehr neu erzeugt.)
# Stehende S/R-Zonen für den KI-Agenten — frei editierbar.
# Format je Eintrag: name = lo, hi, kind (kind: support | demand |
# resistance | supply | invalidation; Einzel-Level: lo == hi)
"zones": {
"demand": "74.98, 76.75, demand",
"support": "76.40, 77.00, support",
"res_low": "81.24, 82.64, resistance",
"fvg": "88.00, 90.00, resistance",
"res_top": "94.72, 94.72, resistance",
"invalid": "75.40, 75.80, invalidation",
},
}
def parse_zones(cfg) -> list[dict]:
"""Liest die [zones]-Sektion in eine Liste {name, lo, hi, kind}."""
out: list[dict] = []
try:
if not cfg.has_section("zones"):
return out
for name, val in cfg["zones"].items():
parts = [p.strip() for p in (val or "").split(",")]
if len(parts) < 2:
continue
try:
lo, hi = float(parts[0]), float(parts[1])
except ValueError:
continue
kind = parts[2].lower() if len(parts) >= 3 else "level"
if lo > hi:
lo, hi = hi, lo
out.append({"name": name, "lo": lo, "hi": hi, "kind": kind})
except Exception:
pass
return out
def load_config() -> configparser.ConfigParser:
"""Liest oil_widget_config.ini, ergänzt fehlende Defaults."""
cfg = configparser.ConfigParser()
if CONFIG_FILE.exists():
cfg.read(CONFIG_FILE, encoding="utf-8")
changed = False
for section, defaults in DEFAULT_CONFIG.items():
if section not in cfg:
cfg[section] = {}
changed = True
for k, v in defaults.items():
if k not in cfg[section]:
cfg[section][k] = v
changed = True
if changed:
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
f.write("# Config für den Oil Trading Server (server.py)\n")
f.write("# Claude-Key (KI-Copilot): https://console.anthropic.com/\n\n")
cfg.write(f)
log.info(f"Config aktualisiert: {CONFIG_FILE}")
if not cfg["anthropic"]["api_key"]:
log.warning("Trage deinen Claude-Key in [anthropic] api_key ein")
return cfg
def save_config(cfg: configparser.ConfigParser):
"""Speichert die Config-Datei (nach Änderungen über das Widget)."""
try:
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
cfg.write(f)
except Exception as e:
log.error(f"Config-Save fehlgeschlagen: {e}")
+159
View File
@@ -0,0 +1,159 @@
"""
core/daily_levels.py — Tägliche WTI-Intraday-Level per Web-Recherche
=====================================================================
Holt einmal morgens (Engine-Scheduler) über **z.ai/GLM mit Web-Suche**
(`web_search`-Tool, Thinking aus) die heutigen Schlüssel-Level (Support /
Widerstandsband / Bias) für WTI und liefert sie als **Kontext**: fließen in
S/R-Anzeige, Close-Alarme und Verdict — NICHT in die Handelsrichtung
(Dritt-Prognose, kein gemessener Edge).
Strenge Plausibilitätsprüfung gegen den aktuellen Kurs verhindert, dass
halluzinierte Zahlen als Zonen landen (fail-safe: bei Zweifel kein Update).
"""
from __future__ import annotations
import threading
import time
from core.logger import get_logger
log = get_logger("daily")
_MAX_DEV = 0.12 # Level muss innerhalb ±12 % des Kurses liegen (sonst verworfen)
_PROMPT = (
"Du bist Rohstoff-Marktanalyst. Suche im Web die heutige INTRADAY-Lage für "
"WTI-Rohöl (US Crude, aktueller Kurs ~{price:.2f} USD). Nenne die für HEUTE "
"wichtigsten technischen Level rund um den aktuellen Kurs.\n\n"
"Antworte AUSSCHLIESSLICH in genau diesem Format (nur Zahlen, Punkt als "
"Dezimaltrenner, deutsche Sprache):\n"
"SUPPORT: <preis unter dem Kurs>\n"
"RESISTANCE_LOW: <preis über dem Kurs>\n"
"RESISTANCE_HIGH: <preis über dem Kurs, >= RESISTANCE_LOW>\n"
"BIAS: bullish|bearish|neutral\n"
"SUMMARY: <1-2 Sätze, nur preisbewegende Faktoren>\n\n"
"Die Level müssen NAHE am aktuellen Kurs liegen (Intraday, keine Jahresziele). "
"Keine Disclaimer, keine Einleitung."
)
class DailyLevels:
def __init__(self, api_key: str, base_url: str = "https://api.z.ai/api/paas/v4",
model: str = "glm-4.5-flash"):
self.api_key = (api_key or "").strip()
self.base_url = (base_url or "https://api.z.ai/api/paas/v4").rstrip("/")
self.model = (model or "glm-4.5-flash").strip()
self.support: float | None = None
self.res_lo: float | None = None
self.res_hi: float | None = None
self.bias: str = "neutral"
self.summary: str = ""
self.ts: float = 0.0
self.error: str | None = None
self._lock = threading.Lock()
def is_configured(self) -> bool:
return bool(self.api_key)
@staticmethod
def _num(line: str):
s = line.split(":", 1)[1] if ":" in line else line
s = s.replace(",", ".")
buf = ""
for c in s:
if c.isdigit() or c == ".":
buf += c
elif buf:
break
try:
return float(buf)
except ValueError:
return None
def _call_llm(self, prompt: str) -> str:
"""z.ai/GLM (OpenAI-kompatibel) mit Web-Suche + Thinking AUS (sonst geht
das Token-Budget komplett ins „Denken")."""
import requests
resp = requests.post(
self.base_url + "/chat/completions",
headers={"Authorization": "Bearer " + self.api_key},
json={"model": self.model,
"messages": [{"role": "user", "content": prompt}],
"tools": [{"type": "web_search",
"web_search": {"enable": True, "search_result": True}}],
"thinking": {"type": "disabled"},
"max_tokens": 500},
timeout=90)
resp.raise_for_status()
return (resp.json()["choices"][0]["message"].get("content") or "").strip()
def run(self, price: float) -> bool:
"""Holt + validiert die Tages-Level. price = aktueller Kurs (Anker +
Plausibilitätsbasis). True bei erfolgreichem, plausiblem Update."""
if not self.is_configured():
with self._lock:
self.error = "Kein z.ai-Key"
return False
if not price or price <= 0:
with self._lock:
self.error = "Kein Kurs als Anker"
return False
try:
text = self._call_llm(_PROMPT.format(price=price))
sup = rlo = rhi = None
bias, summary = "neutral", ""
for raw in text.split("\n"):
low = raw.strip().lower()
if low.startswith("support:"): sup = self._num(raw)
elif low.startswith("resistance_low:"): rlo = self._num(raw)
elif low.startswith("resistance_high:"): rhi = self._num(raw)
elif low.startswith("bias:"):
v = low.split(":", 1)[1]
bias = ("bullish" if "bull" in v else
"bearish" if "bear" in v else "neutral")
elif low.startswith("summary:"):
summary = raw.split(":", 1)[1].strip()
ok, why = self._validate(price, sup, rlo, rhi)
if not ok:
with self._lock:
self.error = f"unplausibel: {why}"
log.warning(f"Tages-Level verworfen ({why}) — "
f"sup={sup} rlo={rlo} rhi={rhi} bei Kurs {price:.2f}")
return False
with self._lock:
self.support, self.res_lo, self.res_hi = sup, rlo, rhi
self.bias, self.summary = bias, summary
self.ts, self.error = time.time(), None
log.info(f"Tages-Level: Sup {sup:.2f} · Res {rlo:.2f}-{rhi:.2f} · "
f"Bias {bias} · {summary[:70]}")
return True
except Exception as e:
with self._lock:
self.error = str(e)[:120]
log.warning(f"DailyLevels.run: {e}")
return False
def _validate(self, price, sup, rlo, rhi):
if sup is None or rlo is None or rhi is None:
return False, "Level fehlen"
if not (sup < price < rhi):
return False, "Reihenfolge support<Kurs<resistance verletzt"
if rlo > rhi:
return False, "res_low>res_high"
for lv in (sup, rlo, rhi):
if abs(lv - price) / price > _MAX_DEV:
return False, f"{lv} >{_MAX_DEV*100:.0f}% vom Kurs entfernt"
return True, ""
def zone_lines(self) -> list[float]:
with self._lock:
return [x for x in (self.support, self.res_lo, self.res_hi)
if x is not None]
def snapshot(self) -> dict:
with self._lock:
return {"support": self.support, "res_lo": self.res_lo,
"res_hi": self.res_hi, "bias": self.bias,
"summary": self.summary, "ts": self.ts, "error": self.error}
+251
View File
@@ -0,0 +1,251 @@
"""
core/elliott.py — Elliott-Wave-/FVG-Heuristik
==============================================
Prinzipienbasierter (NICHT perfekter) Elliott-Wave-Motor als Analyse-Input
für den KI-Agenten. EW-Zählung ist diskretionär — dieser Motor liefert eine
*plausible* Zählung mit Validitäts-Flag, keine Gewissheit.
Was er macht:
1. ATR-ZigZag → Swing-Pivots (H/L) und Legs.
2. Impuls-Erkennung: 5 Legs als 1-2-3-4-5, geprüft gegen die drei harten
EW-Regeln (W2 < Start, W3 nicht der kürzeste, W4 ohne W1-Überlappung).
3. Stand im Zyklus: vollständiger Impuls (→ Reversal-Watch) oder laufende
Welle 5/3 (→ Fib-Extension-Ziel projizieren).
4. FVG-Erkennung (3-Kerzen-Imbalance) der jüngsten unfilled Gaps.
5. Erschöpfungs-Flag, wenn der Kurs das projizierte Ziel erreicht/überschritten hat.
Output via snapshot() — wird in den Agent-Kontext gegeben, damit das LLM mit
EW-Struktur (Zielzone, Erschöpfung, FVG) argumentiert.
"""
from __future__ import annotations
import threading
import time
import MetaTrader5 as mt5
from core.mt5_utils import mt5_lock
from core.logger import get_logger
log = get_logger("elliott")
_N_BARS = 240
_ATR_PERIOD = 14
_ZZ_ATR = 0.7 # Swing-Umkehr ab dieser ATR-Bewegung (etwas grober als wave_rec)
_FVG_LOOKBACK = 60 # Kerzen, in denen nach offenen FVGs gesucht wird
_STALE_S = 180
_TF_LABELS = {
mt5.TIMEFRAME_M1: "M1", mt5.TIMEFRAME_M5: "M5", mt5.TIMEFRAME_M15: "M15",
mt5.TIMEFRAME_M30: "M30", mt5.TIMEFRAME_H1: "H1", mt5.TIMEFRAME_H4: "H4",
}
def _atr(highs, lows, closes, period=_ATR_PERIOD):
trs = [max(highs[i] - lows[i], abs(highs[i] - closes[i - 1]),
abs(lows[i] - closes[i - 1])) for i in range(1, len(highs))]
return (sum(trs[-period:]) / min(len(trs), period)) if trs else None
def _zigzag(highs, lows, thr):
"""ATR-ZigZag → Liste (idx, price, kind 'H'/'L'), chronologisch."""
n = len(highs)
pivots = []
direction = 0
hi_idx, hi = 0, highs[0]
lo_idx, lo = 0, lows[0]
for i in range(1, n):
if highs[i] > hi:
hi, hi_idx = highs[i], i
if lows[i] < lo:
lo, lo_idx = lows[i], i
if direction >= 0 and hi - lows[i] >= thr:
pivots.append((hi_idx, hi, "H")); direction = -1
lo, lo_idx = lows[i], i
elif direction <= 0 and highs[i] - lo >= thr:
pivots.append((lo_idx, lo, "L")); direction = 1
hi, hi_idx = highs[i], i
return pivots
def _detect_fvg(highs, lows, lookback=_FVG_LOOKBACK):
"""Fair Value Gaps (3-Kerzen-Imbalance) der jüngsten Kerzen, noch offen.
Bullish FVG: low[i] > high[i-2] (Lücke nach oben).
Bearish FVG: high[i] < low[i-2] (Lücke nach unten)."""
n = len(highs)
cur = (highs[-1] + lows[-1]) / 2.0
out = []
for i in range(max(2, n - lookback), n):
if lows[i] > highs[i - 2]: # bullish FVG (Support unter dem Kurs)
lo, hi = highs[i - 2], lows[i]
if cur >= lo: # noch nicht nach unten durchbrochen
out.append(("bullish", lo, hi))
elif highs[i] < lows[i - 2]: # bearish FVG (Widerstand über dem Kurs)
lo, hi = highs[i], lows[i - 2]
if cur <= hi: # noch nicht nach oben durchbrochen
out.append(("bearish", lo, hi))
if not out:
return None
typ, lo, hi = out[-1] # jüngster offener FVG
return {"type": typ, "low": round(lo, 3), "high": round(hi, 3),
"mid": round((lo + hi) / 2.0, 3)}
def _label_impulse(pivots):
"""Versucht, die letzten Pivots als 5-Wellen-Impuls zu labeln.
Liefert dict mit Zählung + Validität oder None.
Down-Impuls: H L H L H L (W1 L, W2 H, W3 L, W4 H, W5 L)
Up-Impuls spiegelbildlich."""
if len(pivots) < 5:
return None
# bis zu 6 letzte Pivots betrachten
p = pivots[-6:]
prices = [x[1] for x in p]
kinds = [x[2] for x in p]
# Richtung aus dem Muster: beginnt mit H → Down-Impuls, mit L → Up-Impuls
# Wir brauchen alternierende Kinds.
if any(kinds[i] == kinds[i + 1] for i in range(len(kinds) - 1)):
return None # nicht sauber alternierend
down = kinds[0] == "H"
# Indizes der Wellen-Endpunkte (Start=p[0])
# 5 Legs brauchen 6 Pivots; bei 5 Pivots ist W5 noch offen.
have = len(p)
def leg(a, b):
return abs(prices[b] - prices[a])
if have >= 6:
start, w1, w2, w3, w4, w5 = prices[-6:]
L1, L3, L5 = leg(-6, -5), leg(-4, -3), leg(-2, -1)
# EW-Regeln
if down:
r2 = w2 < start # W2-Hoch unter Start
r4 = w4 < w1 # W4-Hoch unter W1-Tief (keine Überlappung)
else:
r2 = w2 > start
r4 = w4 > w1
r3 = L3 >= min(L1, L5) and not (L3 < L1 and L3 < L5) # W3 nicht der kürzeste
valid = r2 and r3 and r4
return {"pattern": "impulse_down" if down else "impulse_up",
"wave": "5", "complete": True, "valid": valid,
"w5_end": round(prices[-1], 3),
"w4_end": round(prices[-2], 3),
"w1_len": round(L1, 3),
"dir": "down" if down else "up"}
else: # 5 Pivots: W4 fertig, W5 läuft noch
start, w1, w2, w3, w4 = prices[-5:]
L1, L3 = leg(-5, -4), leg(-3, -2)
if down:
r2 = w2 < start; r4 = w4 < w1
else:
r2 = w2 > start; r4 = w4 > w1
r3 = L3 >= L1 * 0.6 # W3 mindestens vergleichbar mit W1
valid = r2 and r3 and r4
return {"pattern": "impulse_down" if down else "impulse_up",
"wave": "5", "complete": False, "valid": valid,
"w4_end": round(prices[-1], 3), "w1_len": round(L1, 3),
"dir": "down" if down else "up"}
class ElliottAnalyzer:
def __init__(self, timeframe: int = mt5.TIMEFRAME_M15):
self._lock = threading.Lock()
self._tf = timeframe
self._snap: dict = {}
self._ts: float = 0.0
self._error: str | None = None
def set_timeframe(self, tf: int):
with self._lock:
self._tf = tf
self._snap = {}
self._ts = 0.0
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)
if bars is None or len(bars) < _ATR_PERIOD + 10:
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]
pivots = _zigzag(highs, lows, _ZZ_ATR * atr)
fvg = _detect_fvg(highs, lows)
snap = self._build(pivots, cur, atr, fvg, tf)
with self._lock:
self._snap = snap
self._ts = time.time()
self._error = None
# Nur bei ÄNDERUNG loggen — lief vorher je Tick (~6k identische Zeilen
# pro 5-MB-Logrotation) und flutete das Log.
line = (f"{sym} {snap.get('pattern')}/{snap.get('wave')} "
f"target={snap.get('target')} exhausted={snap.get('exhaustion')}")
if line != getattr(self, "_last_log_line", None):
self._last_log_line = line
log.info(line)
def _build(self, pivots, cur, atr, fvg, tf):
tf_lbl = _TF_LABELS.get(tf, str(tf))
out = {"tf": tf_lbl, "pattern": "unclear", "wave": "?",
"dir": None, "target": None, "target_label": None,
"exhaustion": False, "invalidation": None,
"valid": False, "fvg": fvg, "n_pivots": len(pivots),
"note": ""}
imp = _label_impulse(pivots)
if not imp:
out["note"] = "kein sauberer Impuls erkennbar"
return out
out.update(pattern=imp["pattern"], dir=imp["dir"], valid=imp["valid"])
down = imp["dir"] == "down"
if not imp["complete"]:
# Welle 5 läuft → Ziel = W4-Ende ∓ (1.0 / 1.618) × W1-Länge
w4 = imp["w4_end"]; l1 = imp["w1_len"]
t100 = w4 - l1 if down else w4 + l1
t162 = w4 - 1.618 * l1 if down else w4 + 1.618 * l1
out["wave"] = "5 (laufend)"
out["target"] = round(t162, 3)
out["target_label"] = "1.618 W5"
out["invalidation"] = round(w4, 3) # über/unter W4 = Zählung fraglich
# Erschöpfung, wenn Kurs das 1.0-Ziel erreicht/überschritten hat
reached = (cur <= t100) if down else (cur >= t100)
out["exhaustion"] = reached
out["note"] = (f"Welle 5 {'abwärts' if down else 'aufwärts'} läuft, "
f"Ziel ~{out['target']} (1.0 bei ~{round(t100,3)})"
+ (" — Ziel erreicht, Reversal-Risiko" if reached else ""))
else:
# Impuls vollständig → Reversal in Gegenrichtung wahrscheinlich
w5 = imp["w5_end"]; l1 = imp["w1_len"]
out["wave"] = "5 (vollendet)"
out["dir"] = imp["dir"]
# Reversal-Ziele = Fib-Retracement des Gesamtimpulses (grob via W1-Länge)
out["target"] = round((w5 + l1) if down else (w5 - l1), 3)
out["target_label"] = "Reversal ~0.3820.618"
out["invalidation"] = round(w5, 3)
# nach Vollendung gilt der Impuls als erschöpft
out["exhaustion"] = True
out["note"] = (f"Impuls {'abwärts' if down else 'aufwärts'} vollendet bei "
f"{w5} → Reversal {'aufwärts' if down else 'abwärts'} wahrscheinlich")
return out
def snapshot(self) -> dict:
with self._lock:
d = dict(self._snap)
d["error"] = self._error
d["last_update"] = self._ts
d["stale"] = (not self._ts) or (time.time() - self._ts > _STALE_S)
return d
+2018
View File
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
"""
core/gaps.py — GapAnalyzer
============================
Erkennt ungefüllte Kurslücken (Gaps) im Tageschart (D1) und liefert ihre
Fill-Target-Levels als S/R-Kandidaten (Magnete). Gleiche Rolle wie `daily_levels`
/ `SRDetector`: NUR Kontext/Anzeige + Konfidenz (über `_sr_levels` → Wellen-
Konfidenz → Autotrader), KEINE eigene Handelsrichtung.
Gap-Definition (Vakuum zwischen Vortag und Folgetag):
UP : Low(heute) > High(gestern) → Lücke [High_gestern … Low_heute], füllt bei High_gestern
DOWN : High(heute) < Low(gestern) → Lücke [High_heute … Low_gestern], füllt bei Low_gestern
„Gefüllt" = ein späterer Bar handelt wieder in/durch das Vakuum.
"""
from __future__ import annotations
import threading
import time
import datetime as dt
import MetaTrader5 as mt5
from core.mt5_utils import mt5_lock
from core.logger import get_logger
log = get_logger("gaps")
_BROKER_OFFSET_S = 3 * 3600 # Brokerzeit (UTC+3) → ~UTC für die Datums-Zuordnung
class GapAnalyzer:
"""Thread-sicher, fail-safe. `maybe_refresh()` drosselt den MT5-Zugriff."""
def __init__(self, refresh_s: int = 600, lookback_days: int = 400,
year: int | None = None):
self._lock = threading.Lock()
self._gaps: list[dict] = [] # offene (ungefüllte) Gaps
self._n_total = 0
self._ts = 0.0
self._next_run = 0.0
self._refresh_s = refresh_s
self._lookback = lookback_days
self._year = year # None = aktuelles Jahr
def maybe_refresh(self, sym: str | None):
"""Im Loop aufrufen — holt die D1-Bars höchstens alle `refresh_s`."""
if not sym or time.time() < self._next_run:
return
self._next_run = time.time() + self._refresh_s
self.detect(sym)
def detect(self, sym: str):
try:
with mt5_lock(timeout=3) as got:
if not got:
self._next_run = time.time() + 30 # gleich nochmal versuchen
return
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_D1, 0, self._lookback)
if bars is None or len(bars) < 2:
return
year = self._year or dt.datetime.now().year
rows = []
for b in bars:
d = dt.datetime.fromtimestamp(
int(b["time"]) - _BROKER_OFFSET_S, tz=dt.timezone.utc).date()
rows.append({"date": d, "h": float(b["high"]),
"l": float(b["low"]), "c": float(b["close"])})
rows = [r for r in rows if r["date"].year == year]
if len(rows) < 2:
return
gaps = []
for i in range(1, len(rows)):
p, cur = rows[i - 1], rows[i]
if cur["l"] > p["h"]:
gaps.append({"i": i, "date": cur["date"].isoformat(), "dir": "up",
"lo": round(p["h"], 3), "hi": round(cur["l"], 3),
"fill": round(p["h"], 3)})
elif cur["h"] < p["l"]:
gaps.append({"i": i, "date": cur["date"].isoformat(), "dir": "down",
"lo": round(cur["h"], 3), "hi": round(p["l"], 3),
"fill": round(p["l"], 3)})
openg = []
for g in gaps:
later = rows[g["i"] + 1:]
if g["dir"] == "up":
mn = min((r["l"] for r in later), default=g["hi"])
filled = mn <= g["lo"]
else:
mx = max((r["h"] for r in later), default=g["lo"])
filled = mx >= g["hi"]
g["size"] = round(g["hi"] - g["lo"], 3)
if not filled:
g.pop("i", None)
openg.append(g)
with self._lock:
self._gaps = openg
self._n_total = len(gaps)
self._ts = time.time()
log.info(f"Gaps {year}: {len(gaps)} gesamt, {len(openg)} offen")
except Exception as e:
log.warning(f"GapAnalyzer.detect: {e}")
def zone_lines(self) -> list[float]:
"""Fill-Target-Levels (für die S/R-Kandidaten / Magnete)."""
with self._lock:
return [g["fill"] for g in self._gaps]
def nearest(self, price: float | None) -> dict:
"""Nächstes offenes Gap-Fill-Level über/unter dem Preis."""
if price is None:
return {"above": None, "below": None}
with self._lock:
fills = [g["fill"] for g in self._gaps]
above = sorted(f for f in fills if f > price)
below = sorted((f for f in fills if f < price), reverse=True)
return {"above": above[0] if above else None,
"below": below[0] if below else None}
def snapshot(self) -> dict:
with self._lock:
return {"gaps": list(self._gaps), "n_open": len(self._gaps),
"n_total": self._n_total, "last_update": self._ts}
+868
View File
@@ -0,0 +1,868 @@
"""
core/history.py — SQLite-basierter Verlaufs-Logger
====================================================
Speichert Trades, KI-Analysen, Empfehlungen und Signale in einer lokalen
SQLite-Datenbank für spätere statistische Auswertung.
Tabellen:
trades — jeder geöffnete + geschlossene Trade
ai_analyses — jede ChatGPT-Antwort
recommendations — Algorithm-Empfehlungen (gefiltert: alle 60 s)
signals — Reversal, Breakout, EMA-Cross etc.
Alle Schreiboperationen sind thread-safe und werden in einem internen
Lock serialisiert. Lese-Queries (für Stats) können parallel laufen.
Wichtig: Diese Klasse macht KEIN Machine Learning. Sie loggt nur.
Auswertung passiert separat über die `stats_*`-Methoden.
"""
from __future__ import annotations
import sqlite3
import threading
import time
import json
from pathlib import Path
from datetime import datetime, timedelta
# history sendet selbst kein Telegram mehr — Close-Pushes laufen über die Engine
# (Flip-Close-Alarm / Notfall-/Gewinn-Auto-Close).
SCHEMA = [
"""
CREATE TABLE IF NOT EXISTS trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket INTEGER UNIQUE,
symbol TEXT,
direction TEXT, -- 'BUY' | 'SELL'
lots REAL,
entry_time INTEGER, -- unix timestamp (s)
entry_price REAL,
sl_at_entry REAL,
tp_at_entry REAL,
exit_time INTEGER,
exit_price REAL,
pnl REAL,
closed_by TEXT, -- 'manual' | 'sl' | 'tp' | 'trail' | 'unknown'
ai_sentiment TEXT, -- KI-Bewertung beim Einstieg
ai_confidence INTEGER, -- 0-100
rec_signal TEXT, -- 'LONG' | 'SHORT' | 'WARTEN'
rec_score REAL, -- -1.0 .. +1.0
setup TEXT, -- 'TREND_PULLBACK_LONG' | 'BREAKOUT_SHORT' | ...
regime TEXT, -- 'trend_up' | 'trend_down' | 'range' | 'transition'
rsi_at_entry REAL, -- RSI(14) M15 beim Einstieg
news_score REAL -- News-Sentiment -1..+1 beim Einstieg
)
""",
"""
CREATE TABLE IF NOT EXISTS ai_analyses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER,
sentiment TEXT,
confidence INTEGER,
summary TEXT,
drivers_json TEXT,
cost_estimate REAL,
model TEXT
)
""",
"""
CREATE TABLE IF NOT EXISTS recommendations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER,
signal TEXT,
score REAL,
conf_pct INTEGER,
angle_m5 REAL,
angle_m15 REAL,
angle_m30 REAL,
angle_h1 REAL,
reversal TEXT,
ai_sentiment TEXT,
ai_confidence INTEGER,
setup TEXT,
regime TEXT,
rsi REAL,
news_score REAL
)
""",
"""
CREATE TABLE IF NOT EXISTS intended_trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
open_ts INTEGER,
open_price REAL,
direction TEXT, -- 'BUY' | 'SELL'
sl REAL,
tp REAL,
setup TEXT,
regime TEXT,
rsi REAL,
score REAL,
conf_pct INTEGER,
news_score REAL,
ai_sentiment TEXT,
ai_confidence INTEGER,
close_ts INTEGER, -- NULL = noch offen
close_price REAL,
close_reason TEXT, -- 'sl' | 'tp' | 'flip' | 'expired' | 'disabled'
pnl_pct REAL -- (close-open)/open * 100, vorzeichenrichtig
)
""",
"""
CREATE TABLE IF NOT EXISTS signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER,
signal_type TEXT, -- 'reversal' | 'breakout' | 'ema_cross'
direction TEXT, -- 'bullish' | 'bearish'
price REAL,
details_json TEXT
)
""",
"""
CREATE TABLE IF NOT EXISTS pbreak_predictions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER, -- lokale Epoch (wie trades/recommendations)
symbol TEXT,
direction TEXT, -- 'LONG' | 'SHORT' (Positionsrichtung)
level REAL,
p_break REAL, -- geglättete P(break) 0..100 beim Touch
predicted TEXT, -- 'break' | 'bounce' (P(break) vs. sr_close_pbreak)
price_at_pred REAL, -- bid beim Touch
confirm_price REAL, -- level ± 0,5×ATR in Richtung Durchbruch
reject_price REAL, -- level ± 0,5×ATR in Richtung Abprall
atr REAL,
outcome TEXT, -- NULL bis ausgewertet, dann 'break' | 'bounce'
outcome_ts INTEGER,
correct INTEGER -- NULL bis ausgewertet, dann 0/1
)
""",
]
# Indizes — werden NACH den Migrationen ausgeführt, weil sie Spalten
# referenzieren, die u.U. erst per ALTER TABLE hinzugefügt werden.
INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_trades_entry ON trades(entry_time)",
"CREATE INDEX IF NOT EXISTS idx_trades_exit ON trades(exit_time)",
"CREATE INDEX IF NOT EXISTS idx_trades_setup ON trades(setup)",
"CREATE INDEX IF NOT EXISTS idx_trades_closed_by ON trades(closed_by)",
"CREATE INDEX IF NOT EXISTS idx_trades_open ON trades(exit_time) WHERE exit_time IS NULL",
"CREATE INDEX IF NOT EXISTS idx_ai_ts ON ai_analyses(timestamp)",
"CREATE INDEX IF NOT EXISTS idx_rec_ts ON recommendations(timestamp)",
"CREATE INDEX IF NOT EXISTS idx_rec_setup ON recommendations(setup)",
"CREATE INDEX IF NOT EXISTS idx_signals_ts ON signals(timestamp)",
"CREATE INDEX IF NOT EXISTS idx_intended_open ON intended_trades(open_ts)",
"CREATE INDEX IF NOT EXISTS idx_intended_close ON intended_trades(close_ts)",
"CREATE INDEX IF NOT EXISTS idx_pbreak_ts ON pbreak_predictions(ts)",
"CREATE INDEX IF NOT EXISTS idx_pbreak_open ON pbreak_predictions(outcome) WHERE outcome IS NULL",
]
# Migrations: Spalten, die in alten DBs evtl. fehlen.
# `ALTER TABLE ADD COLUMN` ist in SQLite idempotent über try/except.
MIGRATIONS = [
("trades", "setup", "TEXT"),
("trades", "regime", "TEXT"),
("trades", "rsi_at_entry", "REAL"),
("trades", "news_score", "REAL"),
("trades", "commission", "REAL"),
("recommendations","setup", "TEXT"),
("recommendations","regime", "TEXT"),
("recommendations","rsi", "REAL"),
("recommendations","news_score", "REAL"),
]
class HistoryLogger:
"""Persistenter SQLite-Logger für Trading-Verlauf."""
def __init__(self, db_path: Path,
rec_min_interval_s: int = 60):
self.db_path = db_path
self._lock = threading.Lock()
self._last_rec_ts = 0 # Throttling für recommendations
self.rec_min_interval = rec_min_interval_s
self._telegram: dict = {"enabled": False, "token": "", "chat_id": ""}
self._last_optimize_ts: float = 0.0
self._init_db()
def configure_telegram(self, *, enabled: bool, token: str, chat_id: str):
"""Telegram-Benachrichtigungen konfigurieren (nach Config-Load aufrufen)."""
self._telegram = {"enabled": enabled, "token": token, "chat_id": chat_id}
# ── Verbindung & Schema ─────────────────
def _connect(self):
# Eine neue Verbindung pro Thread vermeidet SQLite-Threading-Issues.
# Da wir alles unter Lock haben, ist eine Connection auch ok aber wir
# bleiben safer mit Thread-Local Verbindungen.
conn = sqlite3.connect(str(self.db_path), timeout=5.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode = WAL")
now = time.time()
if now - self._last_optimize_ts > 86400:
conn.execute("PRAGMA optimize")
self._last_optimize_ts = now
return conn
def _init_db(self):
with self._connect() as conn:
# 1. Tabellen anlegen (nur Spalten, keine Indizes)
for stmt in SCHEMA:
conn.execute(stmt)
# 2. Migrationen: fehlende Spalten in bestehenden DBs nachziehen.
# MUSS vor den CREATE-INDEX-Statements laufen, weil manche
# Indizes auf Spalten zeigen, die erst hier hinzukommen.
for table, col, ctype in MIGRATIONS:
try:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ctype}")
except sqlite3.OperationalError:
pass # Spalte existiert bereits
# 3. Jetzt sind alle Spalten garantiert da → Indizes anlegen
for stmt in INDEXES:
conn.execute(stmt)
conn.commit()
# ══════════════════════════════════════════
# WRITE-METHODEN
# ══════════════════════════════════════════
def log_trade_open(self, *, ticket: int, symbol: str, direction: str,
lots: float, entry_price: float,
sl_at_entry: float | None = None,
tp_at_entry: float | None = None,
ai_sentiment: str | None = None,
ai_confidence: int | None = None,
rec_signal: str | None = None,
rec_score: float | None = None,
setup: str | None = None,
regime: str | None = None,
rsi_at_entry: float | None = None,
news_score: float | None = None):
"""Loggt das Öffnen eines Trades. Idempotent über UNIQUE(ticket)."""
# Guard (Fix 2026-07-19): 0-Lot-/Preis-lose Einträge sind Reconcile-
# Artefakte (real: Ticket 47966457, 0.0L, closed_by=unknown) — sie
# verfälschen WR/Statistik. Nicht loggen.
if not lots or lots <= 0 or not entry_price or entry_price <= 0:
return
now_ts = int(time.time())
with self._lock, self._connect() as conn:
conn.execute("""
INSERT OR IGNORE INTO trades
(ticket, symbol, direction, lots,
entry_time, entry_price, sl_at_entry, tp_at_entry,
ai_sentiment, ai_confidence, rec_signal, rec_score,
setup, regime, rsi_at_entry, news_score)
VALUES (?,?,?,?, ?,?,?,?, ?,?,?,?, ?,?,?,?)
""", (ticket, symbol, direction, lots,
now_ts, entry_price, sl_at_entry, tp_at_entry,
ai_sentiment, ai_confidence, rec_signal, rec_score,
setup, regime, rsi_at_entry, news_score))
conn.commit()
# Kein Telegram bei Einstieg — nur Ergebnis (Close) wird gesendet
def log_trade_close(self, *, ticket: int, exit_price: float,
pnl: float, closed_by: str = "manual",
exit_ts: int | None = None,
commission: float = 0.0):
"""Schreibt Exit-Daten zu einem bestehenden Trade.
exit_ts kann gesetzt werden für Reconciliation alter Trades.
Sanity-Check: wenn exit_ts < entry_time (kaputter Deal-Lookup),
wird stattdessen time.time() benutzt.
"""
ts = int(exit_ts) if exit_ts else int(time.time())
with self._lock, self._connect() as conn:
# Defensive: exit kann nicht vor entry liegen.
row = conn.execute(
"SELECT entry_time FROM trades "
"WHERE ticket = ? AND exit_time IS NULL", (ticket,)
).fetchone()
if row and row["entry_time"] and ts < row["entry_time"]:
ts = int(time.time())
conn.execute("""
UPDATE trades
SET exit_time = ?, exit_price = ?, pnl = ?,
closed_by = ?, commission = ?
WHERE ticket = ? AND exit_time IS NULL
""", (ts, exit_price, pnl, closed_by, commission, ticket))
conn.commit()
# KEIN Trade-Abschluss-Telegram mehr (User-Vorgabe): Telegram zum Thema
# Schließen kommt nur noch beim echten Signal-Flip (engine._check_close_
# alert „🔔 CLOSE-Signal") sowie bei Notfall-/Gewinn-Auto-Close. Die terse
# „Sym +X"-Bestätigung bei jedem Close ist entfernt.
def log_ai(self, *, sentiment: str, confidence: int,
summary: str, drivers: list,
cost_estimate: float | None,
model: str):
with self._lock, self._connect() as conn:
conn.execute("""
INSERT INTO ai_analyses
(timestamp, sentiment, confidence, summary,
drivers_json, cost_estimate, model)
VALUES (?,?,?,?,?,?,?)
""", (int(time.time()), sentiment, confidence,
summary[:500], json.dumps(drivers, ensure_ascii=False),
cost_estimate, model))
conn.commit()
def log_recommendation(self, *, signal: str, score: float, conf_pct: int,
angles: dict, reversal: str | None,
ai_sentiment: str | None,
ai_confidence: int | None,
setup: str | None = None,
regime: str | None = None,
rsi: float | None = None,
news_score: float | None = None):
"""
Empfehlungs-Logging mit Throttling: nur jede N Sekunden, um die DB
nicht mit identischen Snapshots zu fluten.
"""
now = int(time.time())
if now - self._last_rec_ts < self.rec_min_interval:
return
self._last_rec_ts = now
with self._lock, self._connect() as conn:
conn.execute("""
INSERT INTO recommendations
(timestamp, signal, score, conf_pct,
angle_m5, angle_m15, angle_m30, angle_h1,
reversal, ai_sentiment, ai_confidence,
setup, regime, rsi, news_score)
VALUES (?,?,?,?, ?,?,?,?, ?,?,?, ?,?,?,?)
""", (now, signal, score, conf_pct,
angles.get("M5"), angles.get("M15"),
angles.get("M30"), angles.get("H1"),
reversal, ai_sentiment, ai_confidence,
setup, regime, rsi, news_score))
conn.commit()
def log_signal(self, *, signal_type: str, direction: str,
price: float | None = None, details: dict | None = None):
with self._lock, self._connect() as conn:
conn.execute("""
INSERT INTO signals
(timestamp, signal_type, direction, price, details_json)
VALUES (?,?,?,?,?)
""", (int(time.time()), signal_type, direction, price,
json.dumps(details or {}, ensure_ascii=False)))
conn.commit()
def log_pbreak_prediction(self, *, symbol: str, direction: str, level: float,
p_break: float, predicted: str, price_at_pred: float,
confirm_price: float, reject_price: float,
atr: float) -> int:
"""Loggt EINE Abprall/Durchbruch-Vorhersage bei frischem Level-Touch
(`predicted` = 'break'|'bounce', aus dem live-P(break) vs. `sr_close_pbreak`).
Auswertung passiert separat (`engine._evaluate_pbreak_predictions`, gegen
`candles_m1`). Rückgabe = Zeilen-ID."""
with self._lock, self._connect() as conn:
cur = conn.execute("""
INSERT INTO pbreak_predictions
(ts, symbol, direction, level, p_break, predicted, price_at_pred,
confirm_price, reject_price, atr)
VALUES (?,?,?,?,?,?,?,?,?,?)
""", (int(time.time()), symbol, direction, level, p_break, predicted,
price_at_pred, confirm_price, reject_price, atr))
conn.commit()
return cur.lastrowid
def pbreak_accuracy(self, period: str = "all") -> dict:
"""Trefferquote der Abprall/Durchbruch-Prognose (nur ausgewertete Zeilen)."""
since, until = self._range_to_ts(period)
with self._connect() as conn:
rows = conn.execute("""
SELECT predicted, outcome, correct FROM pbreak_predictions
WHERE outcome IS NOT NULL AND ts BETWEEN ? AND ?
""", (since, until)).fetchall()
pending = conn.execute(
"SELECT COUNT(*) FROM pbreak_predictions WHERE outcome IS NULL"
).fetchone()[0]
n = len(rows)
n_correct = sum(1 for r in rows if r["correct"])
n_break_pred = sum(1 for r in rows if r["predicted"] == "break")
n_bounce_pred = sum(1 for r in rows if r["predicted"] == "bounce")
n_break_correct = sum(1 for r in rows if r["predicted"] == "break" and r["correct"])
n_bounce_correct = sum(1 for r in rows if r["predicted"] == "bounce" and r["correct"])
return {
"n": n, "n_correct": n_correct,
"accuracy": round(100 * n_correct / n, 1) if n else None,
"n_break_pred": n_break_pred, "n_break_correct": n_break_correct,
"break_accuracy": round(100 * n_break_correct / n_break_pred, 1) if n_break_pred else None,
"n_bounce_pred": n_bounce_pred, "n_bounce_correct": n_bounce_correct,
"bounce_accuracy": round(100 * n_bounce_correct / n_bounce_pred, 1) if n_bounce_pred else None,
"pending": pending,
}
# ══════════════════════════════════════════
# STATISTIK-QUERIES
# ══════════════════════════════════════════
@staticmethod
def _range_to_ts(period: str) -> tuple[int, int]:
"""'today' | 'week' | 'month' | 'all' → (since_ts, now_ts)."""
now = int(time.time())
if period == "today":
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
return (int(today.timestamp()), now)
if period == "yesterday":
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
return (int((today - timedelta(days=1)).timestamp()), int(today.timestamp()))
if period == "week":
since = datetime.now() - timedelta(days=7)
return (int(since.timestamp()), now)
if period == "month":
since = datetime.now() - timedelta(days=30)
return (int(since.timestamp()), now)
return (0, now)
def stats_overview(self, period: str = "all") -> dict:
"""Liefert ein Dict mit den Hauptkennzahlen für den gewählten Zeitraum."""
since, until = self._range_to_ts(period)
with self._connect() as conn:
# Trades (nur abgeschlossene)
rows = conn.execute("""
SELECT direction, lots, entry_time, exit_time, entry_price,
exit_price, pnl, closed_by, ai_sentiment, ai_confidence,
rec_signal
FROM trades
WHERE exit_time IS NOT NULL
AND exit_time BETWEEN ? AND ?
ORDER BY exit_time
LIMIT 10000
""", (since, until)).fetchall()
n = len(rows)
wins = [r for r in rows if (r["pnl"] or 0) > 0]
losses = [r for r in rows if (r["pnl"] or 0) < 0]
breakeven = [r for r in rows if (r["pnl"] or 0) == 0]
total_pnl = sum((r["pnl"] or 0) for r in rows)
avg_win = (sum(r["pnl"] for r in wins) / len(wins)) if wins else 0.0
avg_loss = (sum(r["pnl"] for r in losses) / len(losses)) if losses else 0.0
gross_win = sum(r["pnl"] for r in wins)
gross_loss = sum(-r["pnl"] for r in losses)
profit_factor = (gross_win / gross_loss) if gross_loss > 0 else None
# Closed-by Verteilung
cb_counts = {}
for r in rows:
cb = r["closed_by"] or "unknown"
cb_counts[cb] = cb_counts.get(cb, 0) + 1
# KI-Trefferquote
ai_correct = ai_total = 0
for r in rows:
sent = (r["ai_sentiment"] or "").lower()
pnl = r["pnl"] or 0
direction = (r["direction"] or "").upper()
if sent in ("bullish", "bearish") and pnl != 0:
ai_total += 1
# KI bullish + Long-Trade gewonnen, oder bearish + Short gewonnen
ai_aligned = (
(sent == "bullish" and direction == "BUY" and pnl > 0) or
(sent == "bearish" and direction == "SELL" and pnl > 0) or
(sent == "bullish" and direction == "SELL" and pnl < 0) or
(sent == "bearish" and direction == "BUY" and pnl < 0)
)
if ai_aligned:
ai_correct += 1
# Wochentag-Verteilung
weekday_stats = {i: {"wins": 0, "losses": 0} for i in range(7)}
for r in rows:
wd = datetime.fromtimestamp(r["exit_time"]).weekday()
if (r["pnl"] or 0) > 0:
weekday_stats[wd]["wins"] += 1
elif (r["pnl"] or 0) < 0:
weekday_stats[wd]["losses"] += 1
# Stunden-Verteilung
hour_stats = {h: {"wins": 0, "losses": 0} for h in range(24)}
for r in rows:
hr = datetime.fromtimestamp(r["exit_time"]).hour
if (r["pnl"] or 0) > 0:
hour_stats[hr]["wins"] += 1
elif (r["pnl"] or 0) < 0:
hour_stats[hr]["losses"] += 1
# Top-3 Stunden (mind. 2 Trades)
top_hours = sorted(
[(h, s["wins"], s["losses"]) for h, s in hour_stats.items()
if (s["wins"] + s["losses"]) >= 2],
key=lambda x: (x[1] / max(1, x[1]+x[2]), x[1]+x[2]),
reverse=True)[:3]
return {
"period": period,
"since": since,
"until": until,
"n_trades": n,
"n_wins": len(wins),
"n_losses": len(losses),
"n_be": len(breakeven),
"winrate": (len(wins) / n * 100) if n else 0.0,
"total_pnl": total_pnl,
"avg_win": avg_win,
"avg_loss": avg_loss,
"gross_win": gross_win,
"gross_loss": gross_loss,
"profit_factor": profit_factor,
"closed_by": cb_counts,
"ai_correct": ai_correct,
"ai_total": ai_total,
"ai_winrate": (ai_correct / ai_total * 100) if ai_total else None,
"weekday_stats": weekday_stats,
"hour_stats": hour_stats,
"top_hours": top_hours,
}
def setup_stats(self, period: str = "all") -> list[dict]:
"""
Performance-Aufschlüsselung pro Setup-Typ.
Liefert eine Liste {setup, n, wins, losses, winrate, total_pnl, avg_pnl, profit_factor}
sortiert nach total_pnl absteigend.
"""
since, until = self._range_to_ts(period)
with self._connect() as conn:
rows = conn.execute("""
SELECT setup, pnl
FROM trades
WHERE exit_time IS NOT NULL
AND exit_time BETWEEN ? AND ?
AND setup IS NOT NULL
""", (since, until)).fetchall()
groups: dict[str, list[float]] = {}
for r in rows:
groups.setdefault(r["setup"] or "UNKNOWN", []).append(r["pnl"] or 0.0)
out = []
for setup, pnls in groups.items():
wins = [p for p in pnls if p > 0]
losses = [p for p in pnls if p < 0]
gross_w = sum(wins)
gross_l = sum(-p for p in losses)
out.append({
"setup": setup,
"n": len(pnls),
"wins": len(wins),
"losses": len(losses),
"winrate": (len(wins) / len(pnls) * 100) if pnls else 0.0,
"total_pnl": sum(pnls),
"avg_pnl": sum(pnls) / len(pnls) if pnls else 0.0,
"gross_win": gross_w,
"gross_loss": gross_l,
"profit_factor": (gross_w / gross_l) if gross_l > 0 else None,
})
out.sort(key=lambda x: x["total_pnl"], reverse=True)
return out
def setup_multipliers(self, *, min_trades: int = 10,
lookback_days: int = 90) -> dict[str, dict]:
"""
Lernt aus historischen Trades einen Confidence-Multiplikator pro Setup.
Mapping:
Profit-Factor 1.0 (break-even) → 1.0 (kein Effekt)
Profit-Factor ≥ 2.0 (sehr profitabel)→ 1.3 (Score +30 %)
Profit-Factor ≤ 0.5 (klar verlierend)→ 0.5 (Score 50 %)
Lineare Interpolation dazwischen.
Setups unter `min_trades` bekommen Multiplikator 1.0 (Neutral, noch zu
wenig Daten). Lookback begrenzt auf `lookback_days` Tage, damit
Regime-Wechsel berücksichtigt werden.
Rückgabe: {setup_name: {"multiplier": float, "n": int,
"profit_factor": float|None, "winrate": float}}
"""
cutoff = int(time.time()) - lookback_days * 86400
with self._connect() as conn:
rows = conn.execute("""
SELECT setup, pnl
FROM trades
WHERE exit_time IS NOT NULL
AND exit_time >= ?
AND setup IS NOT NULL
""", (cutoff,)).fetchall()
groups: dict[str, list[float]] = {}
for r in rows:
groups.setdefault(r["setup"], []).append(r["pnl"] or 0.0)
out: dict[str, dict] = {}
for setup, pnls in groups.items():
n = len(pnls)
wins = [p for p in pnls if p > 0]
losses = [p for p in pnls if p < 0]
gross_w = sum(wins)
gross_l = sum(-p for p in losses)
pf = (gross_w / gross_l) if gross_l > 0 else (
float("inf") if gross_w > 0 else 1.0)
wr = (len(wins) / n * 100) if n else 0.0
if n < min_trades:
mult = 1.0
else:
# Mapping pf → multiplier (geclampt)
pf_capped = min(max(pf, 0.3), 3.0)
if pf_capped >= 1.0:
# 1.0 → 1.0, 2.0 → 1.3, 3.0 → 1.3 (gecapped)
mult = 1.0 + min(0.30, (pf_capped - 1.0) * 0.30)
else:
# 1.0 → 1.0, 0.5 → 0.5, 0.3 → 0.5 (gecapped)
mult = max(0.50, 1.0 - (1.0 - pf_capped) * 1.0)
out[setup] = {
"multiplier": round(mult, 3),
"n": n,
"profit_factor": pf if pf != float("inf") else None,
"winrate": wr,
}
return out
# ══════════════════════════════════════════
# AUTO-TRADE DRY-RUN
# ══════════════════════════════════════════
def intended_open(self, *, direction: str, open_price: float,
sl: float, tp: float,
setup: str, regime: str | None,
rsi: float | None, score: float, conf_pct: int,
news_score: float | None,
ai_sentiment: str | None,
ai_confidence: int | None) -> int:
"""Loggt einen hypothetischen Auto-Trade. Liefert die neue Row-ID."""
with self._lock, self._connect() as conn:
cur = conn.execute("""
INSERT INTO intended_trades
(open_ts, open_price, direction, sl, tp,
setup, regime, rsi, score, conf_pct,
news_score, ai_sentiment, ai_confidence)
VALUES (?,?,?,?,?, ?,?,?,?,?, ?,?,?)
""", (int(time.time()), open_price, direction, sl, tp,
setup, regime, rsi, score, conf_pct,
news_score, ai_sentiment, ai_confidence))
conn.commit()
return cur.lastrowid
def intended_close(self, *, row_id: int, close_price: float,
close_reason: str):
"""Schließt einen hypothetischen Trade. Berechnet pnl_pct vorzeichenrichtig."""
with self._lock, self._connect() as conn:
row = conn.execute(
"SELECT open_price, direction FROM intended_trades "
"WHERE id = ? AND close_ts IS NULL", (row_id,)
).fetchone()
if not row:
return # bereits geschlossen oder nicht vorhanden
op = row["open_price"] or 0.0
if op <= 0:
pnl_pct = 0.0
else:
diff = (close_price - op) if row["direction"] == "BUY" \
else (op - close_price)
pnl_pct = diff / op * 100.0
conn.execute("""
UPDATE intended_trades
SET close_ts = ?, close_price = ?,
close_reason = ?, pnl_pct = ?
WHERE id = ?
""", (int(time.time()), close_price, close_reason, pnl_pct, row_id))
conn.commit()
def intended_open_list(self) -> list[dict]:
"""Liefert alle noch nicht geschlossenen intended trades."""
with self._connect() as conn:
rows = conn.execute("""
SELECT * FROM intended_trades WHERE close_ts IS NULL
ORDER BY open_ts DESC
""").fetchall()
return [dict(r) for r in rows]
def intended_today_count(self) -> int:
"""Anzahl heute geöffneter hypothetischer Trades (für Daily-Cap)."""
from datetime import datetime as _dt
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
since = int(today.timestamp())
with self._connect() as conn:
return conn.execute(
"SELECT COUNT(*) FROM intended_trades WHERE open_ts >= ?",
(since,)
).fetchone()[0]
def intended_last_close_ts(self) -> int:
"""Timestamp des letzten geschlossenen intended trade (für Cooldown).
Flip-Closes zählen nicht — nach einem Signal-Flip darf die neue
Richtung sofort eröffnet werden (schnelle Breakout-Reaktion)."""
with self._connect() as conn:
row = conn.execute(
"SELECT MAX(close_ts) FROM intended_trades "
"WHERE close_ts IS NOT NULL "
" AND COALESCE(close_reason, '') != 'flip'"
).fetchone()
return int(row[0] or 0)
def intended_summary(self, period: str = "all") -> dict:
"""Performance-Zusammenfassung aller hypothetischen Trades."""
since, until = self._range_to_ts(period)
with self._connect() as conn:
rows = conn.execute("""
SELECT pnl_pct, close_reason, setup FROM intended_trades
WHERE close_ts IS NOT NULL
AND close_ts BETWEEN ? AND ?
""", (since, until)).fetchall()
n = len(rows)
wins = sum(1 for r in rows if (r["pnl_pct"] or 0) > 0)
losses = sum(1 for r in rows if (r["pnl_pct"] or 0) < 0)
total_pnl_pct = sum(r["pnl_pct"] or 0 for r in rows)
avg_pnl_pct = total_pnl_pct / n if n else 0.0
by_reason: dict[str, int] = {}
for r in rows:
by_reason[r["close_reason"] or "?"] = by_reason.get(r["close_reason"] or "?", 0) + 1
return {
"n": n,
"wins": wins,
"losses": losses,
"winrate": (wins / n * 100) if n else 0.0,
"total_pnl_pct": total_pnl_pct,
"avg_pnl_pct": avg_pnl_pct,
"by_reason": by_reason,
}
def last_closed_trades(self, n: int = 5) -> list[dict]:
"""Liefert die letzten n geschlossenen Trades (neueste zuerst)."""
with self._connect() as conn:
rows = conn.execute("""
SELECT direction, lots, entry_time, exit_time,
entry_price, exit_price, pnl, commission, closed_by, setup
FROM trades
WHERE exit_time IS NOT NULL
ORDER BY exit_time DESC
LIMIT ?
""", (n,)).fetchall()
return [dict(r) for r in rows]
def open_trades(self) -> list[dict]:
"""Liefert alle Trades, für die noch kein exit_time eingetragen ist."""
with self._connect() as conn:
rows = conn.execute("""
SELECT id, ticket, symbol, direction, entry_time, entry_price
FROM trades
WHERE exit_time IS NULL
ORDER BY entry_time
""").fetchall()
return [dict(r) for r in rows]
def consistency_report(self) -> dict:
"""
Diagnostiziert Inkonsistenzen in der History-DB.
Liefert ein Dict mit erkannten Problemen:
• n_open_trades_db — Anzahl "offene" Trades in DB
• n_orphan_old — offene Trades älter als 14 Tage (verwaist)
• n_negative_pnl_no_loss — geschlossene Trades mit pnl < 0 aber wins+losses=0
• n_missing_setup — geschlossene Trades ohne setup-Spalte
• n_missing_exit_price — geschlossene Trades ohne exit_price
• duplicate_tickets — Tickets, die mehrfach existieren (sollte 0 sein)
• db_size_mb
• oldest_trade_age_days
"""
now = int(time.time())
out = {
"timestamp": now,
"n_open_trades_db": 0,
"n_orphan_old": 0,
"n_missing_setup": 0,
"n_missing_exit_price": 0,
"n_pnl_zero_closed": 0,
"duplicate_tickets": [],
"db_size_mb": self.db_size_mb(),
"oldest_trade_age_days": None,
}
ORPHAN_AGE_S = 14 * 86400
with self._connect() as conn:
# Open trades
opens = conn.execute(
"SELECT ticket, entry_time FROM trades WHERE exit_time IS NULL"
).fetchall()
out["n_open_trades_db"] = len(opens)
out["n_orphan_old"] = sum(
1 for r in opens if (r["entry_time"] or now) < now - ORPHAN_AGE_S
)
# Geschlossene Trades ohne Setup-Spalte (alte Daten vor Migration)
out["n_missing_setup"] = conn.execute("""
SELECT COUNT(*) FROM trades
WHERE exit_time IS NOT NULL AND (setup IS NULL OR setup = '')
""").fetchone()[0]
# Geschlossene Trades ohne exit_price
out["n_missing_exit_price"] = conn.execute("""
SELECT COUNT(*) FROM trades
WHERE exit_time IS NOT NULL
AND (exit_price IS NULL OR exit_price = 0)
""").fetchone()[0]
# Geschlossene Trades mit pnl = 0 — aber NUR die, die NICHT
# bewusst als 'unknown' markiert sind (Phantom-Cleanup). Trades
# mit closed_by='unknown' AND pnl=0 sind gewollt (uns fehlen
# die echten Exit-Daten), das ist keine Anomalie.
out["n_pnl_zero_closed"] = conn.execute("""
SELECT COUNT(*) FROM trades
WHERE exit_time IS NOT NULL
AND (pnl IS NULL OR pnl = 0)
AND COALESCE(closed_by, '') != 'unknown'
""").fetchone()[0]
# Duplikat-Tickets (UNIQUE-Constraint sollte das verhindern,
# aber wir prüfen trotzdem)
dups = conn.execute("""
SELECT ticket, COUNT(*) c FROM trades
GROUP BY ticket HAVING c > 1
""").fetchall()
out["duplicate_tickets"] = [{"ticket": r["ticket"], "count": r["c"]}
for r in dups]
# Ältester Trade
row = conn.execute(
"SELECT MIN(entry_time) FROM trades"
).fetchone()
if row and row[0]:
out["oldest_trade_age_days"] = (now - row[0]) / 86400.0
return out
def all_trades(self, period: str = "all") -> list[dict]:
"""Vollständige Trade-Liste für CSV-Export."""
since, until = self._range_to_ts(period)
with self._connect() as conn:
rows = conn.execute("""
SELECT * FROM trades
WHERE entry_time BETWEEN ? AND ?
ORDER BY entry_time DESC
""", (since, until)).fetchall()
return [dict(r) for r in rows]
def export_csv(self, file_path: Path, period: str = "all") -> int:
"""Exportiert Trades als CSV. Liefert Anzahl exportierter Zeilen."""
import csv
rows = self.all_trades(period)
if not rows:
return 0
with open(file_path, "w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows(rows)
return len(rows)
def db_size_mb(self) -> float:
try:
return self.db_path.stat().st_size / (1024 * 1024)
except Exception:
return 0.0
def n_total_trades(self) -> int:
with self._connect() as conn:
return conn.execute(
"SELECT COUNT(*) FROM trades WHERE exit_time IS NOT NULL"
).fetchone()[0]
+88
View File
@@ -0,0 +1,88 @@
"""
core/logger.py — Zentralisiertes Logging
==========================================
Schreibt sowohl in Konsole als auch in eine rotierende Logdatei.
Wird von allen anderen Modulen über `from core.logger import log` genutzt.
Verwendung:
log.info("Trade BUY 0.5L @ 74.30")
log.warning("Slow API response")
log.error("Order fehlgeschlagen", exc_info=True)
log.debug("Detail-Info") # nur bei DEBUG-Level sichtbar
Vorteile gegenüber print():
- Persistente Datei-Logs (überleben Crashes / pythonw)
- Log-Rotation (max 5 MB pro Datei, 5 Backups)
- Filterbar nach Komponente (logger.getChild('trade'))
- Zeit + Level pro Eintrag
"""
from __future__ import annotations
import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
# ── Konfiguration ──────────────────────────────
LOG_FILE = Path(__file__).parent.parent / "oil_widget.log"
LOG_MAX_BYTES = 5_000_000
LOG_BACKUP_COUNT = 5
# Format mit Komponente, Level, Zeit
_FILE_FORMAT = "%(asctime)s [%(levelname)-7s] %(name)-14s | %(message)s"
_CONSOLE_FORMAT = "[%(name)s] %(message)s"
def _setup_logger(level: int = logging.INFO) -> logging.Logger:
"""Erstellt den Root-Logger 'oil' mit Datei- und Konsolen-Handler."""
logger = logging.getLogger("oil")
logger.setLevel(logging.DEBUG) # Detail-Filter erfolgt pro Handler
logger.propagate = False
# Doppelte Handler vermeiden (z.B. bei Reload)
if logger.handlers:
return logger
# Datei-Handler komplettes Detail
try:
fh = RotatingFileHandler(
LOG_FILE, maxBytes=LOG_MAX_BYTES,
backupCount=LOG_BACKUP_COUNT, encoding="utf-8")
fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(_FILE_FORMAT,
datefmt="%Y-%m-%d %H:%M:%S"))
logger.addHandler(fh)
except Exception as e:
# Fallback: kein File-Log möglich (z.B. Read-Only-FS)
sys.stderr.write(f"[Logger] Datei-Logging fehlgeschlagen: {e}\n")
# Konsolen-Handler kompakt
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(level)
ch.setFormatter(logging.Formatter(_CONSOLE_FORMAT))
logger.addHandler(ch)
return logger
# Globaler Root-Logger einmalig instanziiert
log = _setup_logger()
def get_logger(component: str) -> logging.Logger:
"""
Liefert einen Sub-Logger pro Komponente, z.B. 'trade', 'mt5', 'ai'.
Erscheint im Log dann als 'oil.trade'.
"""
return log.getChild(component)
def set_console_level(level: str | int):
"""Erlaubt Runtime-Änderung des Konsolen-Log-Levels."""
if isinstance(level, str):
level = getattr(logging, level.upper(), logging.INFO)
for h in log.handlers:
if isinstance(h, logging.StreamHandler) and not isinstance(
h, RotatingFileHandler):
h.setLevel(level)
+60
View File
@@ -0,0 +1,60 @@
"""
core/mailer.py — E-Mail via Microsoft Graph (client-credentials)
=================================================================
Sendet HTML-Mails über die Graph-App-Registrierung in `[graph]`
(tenant_id/client_id/client_secret). Absender = `[graph] sender` oder
Default mailagent@hocks.eu. Ersetzt den PowerShell-Umweg (send_daily_report.ps1)
durch reines Python — so kann die Engine den Tagesreport selbst verschicken.
"""
from __future__ import annotations
from core.logger import get_logger
log = get_logger("mail")
_TOKEN_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
_SENDMAIL = "https://graph.microsoft.com/v1.0/users/{sender}/sendMail"
_DEFAULT_SENDER = "mailagent@hocks.eu"
def send_graph_mail(graph_cfg, subject: str, html: str, to_addr: str) -> bool:
"""True bei Erfolg. `to_addr` darf mehrere Adressen kommagetrennt enthalten.
Fail-safe: loggt + gibt False zurück, wirft nie."""
import requests
g = graph_cfg or {}
tenant = (g.get("tenant_id") or "").strip()
cid = (g.get("client_id") or "").strip()
secret = (g.get("client_secret") or "").strip()
sender = (g.get("sender") or _DEFAULT_SENDER).strip()
recipients = [{"emailAddress": {"address": a.strip()}}
for a in (to_addr or "").split(",") if a.strip()]
if not (tenant and cid and secret) or not recipients:
log.warning("Graph-Config/Empfänger unvollständig — keine E-Mail")
return False
try:
tok = requests.post(
_TOKEN_URL.format(tenant=tenant),
data={"client_id": cid, "client_secret": secret,
"scope": "https://graph.microsoft.com/.default",
"grant_type": "client_credentials"}, timeout=30)
tok.raise_for_status()
access = tok.json().get("access_token")
if not access:
log.warning("Graph: kein access_token"); return False
r = requests.post(
_SENDMAIL.format(sender=sender),
headers={"Authorization": "Bearer " + access},
json={"message": {
"subject": subject,
"body": {"contentType": "HTML", "content": html},
"toRecipients": recipients,
"from": {"emailAddress": {"address": sender}}},
"saveToSentItems": True}, timeout=30)
if r.status_code in (200, 202):
log.info(f"E-Mail an {to_addr}: {subject}")
return True
log.warning(f"Graph sendMail {r.status_code}: {r.text[:200]}")
return False
except Exception as e:
log.warning(f"send_graph_mail: {e}")
return False
+77
View File
@@ -0,0 +1,77 @@
"""
core/market_hours.py — Börsen-Session-Status (Frankfurt / US)
==============================================================
Liefert den aktuellen Session-Status für die Empfehlungs-Logik, das UI und
den KI-Agenten. Zeiten in **Berlin-Lokalzeit** (DST-sicher via zoneinfo):
DE (Frankfurt): 09:00 17:30
US (Wall St.): 15:00 22:00 (Overlap 15:0017:30 = höchste Liquidität)
Nutzen:
"just_opened": die ersten _CAUTION_MIN nach einem Open (Whipsaw-Vorsicht)
"active": welche Sessions gerade offen sind (Liquiditäts-Bonus)
"next_open": nächster Open + Minuten bis dahin (UI-Countdown)
"""
from __future__ import annotations
from datetime import datetime, time, timezone
try:
from zoneinfo import ZoneInfo
_BERLIN = ZoneInfo("Europe/Berlin")
except Exception:
_BERLIN = None
# Sessions in Berlin-Lokalzeit (Start, Ende)
_SESSIONS = {
"DE": (time(9, 0), time(17, 30)),
"US": (time(15, 0), time(22, 0)),
}
_CAUTION_MIN = 20 # erste N Min nach einem Open = volatil → Vorsicht
def _mins(t: time) -> int:
return t.hour * 60 + t.minute
def session_state(now: datetime | None = None) -> dict:
now = now or datetime.now(timezone.utc)
loc = now.astimezone(_BERLIN) if _BERLIN else now
is_weekend = loc.weekday() >= 5 # 5=Sa, 6=So
nowm = loc.hour * 60 + loc.minute
active, since, just_opened, next_open = [], {}, None, None
for name, (o, c) in _SESSIONS.items():
om, cm = _mins(o), _mins(c)
open_now = (not is_weekend) and (om <= nowm <= cm)
if open_now:
active.append(name)
if (not is_weekend) and nowm >= om:
since[name] = nowm - om
if open_now and (nowm - om) < _CAUTION_MIN:
just_opened = name
else:
since[name] = None
if (not is_weekend) and nowm < om:
d = om - nowm
if next_open is None or d < next_open["in_min"]:
next_open = {"name": name, "in_min": d}
if is_weekend:
phase = "Wochenende"
elif just_opened:
phase = f"{just_opened}-Open frisch (volatil)"
elif active:
phase = " + ".join(active) + "-Session offen"
elif next_open:
phase = f"vor {next_open['name']}-Open"
else:
phase = "außerhalb DE/US"
return {
"active": active,
"since_open_min": since,
"just_opened": just_opened,
"next_open": next_open,
"phase": phase,
"weekend": is_weekend,
}
+161
View File
@@ -0,0 +1,161 @@
"""
core/mt5_utils.py — Thread-sicherer MT5-Zugriff + Trading-Hilfsfunktionen
==========================================================================
Globaler Lock für die MetaTrader5-Lib (nicht thread-safe) sowie
alle kleinen Hilfsfunktionen die von TradeManager, TrailingManager
und MT5Data gemeinsam genutzt werden.
"""
from __future__ import annotations
import threading
from contextlib import contextmanager
import MetaTrader5 as mt5
from core.config import (
get_margin_buffer, SL_TF, SL_LOOKBACK, SL_WINDOW, SL_BUFFER_TICKS,
)
from core.logger import get_logger
log = get_logger("mt5")
# ── Globaler MT5-Lock ──────────────────────────
# Die MT5 Python-Lib ist NICHT thread-safe. Alle MT5-Calls laufen
# über diesen Lock. Mit Timeout, damit ein hängender order_send
# (Server antwortet nicht) nicht alle anderen Threads blockiert.
_mt5_lock = threading.Lock()
MT5_LOCK_TIMEOUT_S = 5
@contextmanager
def mt5_lock(timeout: float = MT5_LOCK_TIMEOUT_S):
"""
Context-Manager für den globalen MT5-Lock mit Timeout.
with mt5_lock() as got:
if not got:
return # Lock nicht erhalten → Tick überspringen
... MT5-Calls ...
"""
acquired = _mt5_lock.acquire(timeout=timeout)
if not acquired:
try:
yield False
finally:
pass
else:
try:
yield True
finally:
_mt5_lock.release()
# ── Tick / Symbol-Helpers ─────────────────────
def get_tick(sym: str):
t = mt5.symbol_info_tick(sym)
return t if (t and t.bid > 0 and t.ask > 0) else None
def get_filling(sym: str) -> int:
si = mt5.symbol_info(sym)
if si is None:
return mt5.ORDER_FILLING_IOC
for m in (mt5.ORDER_FILLING_FOK, mt5.ORDER_FILLING_IOC,
mt5.ORDER_FILLING_RETURN):
if si.filling_mode & m:
return m
return mt5.ORDER_FILLING_RETURN
def calc_lots(sym: str, price: float, otype: int) -> float:
si, acc = mt5.symbol_info(sym), mt5.account_info()
if not si or not acc:
return 0.0
mpl = mt5.order_calc_margin(otype, sym, 1.0, price)
if not mpl or mpl <= 0:
return 0.0
step = si.volume_step
lots = round(((acc.margin_free * get_margin_buffer() / mpl) // step) * step, 4)
# Margin-Guard: reicht die freie Margin nicht mal fürs Mindestvolumen, 0.0
# zurückgeben → Caller meldet sauber „Lot-Fehler" statt Broker-Reject mit
# kryptischem retcode (früher: max(volume_min, …) erzwang unbezahlbare Größe).
if lots < si.volume_min:
return 0.0
return min(lots, si.volume_max)
def calc_lots_risk(sym: str, price: float, otype: int,
sl_distance: float | None, risk_frac: float) -> float:
"""Risiko-basierte Lot-Größe: Verlust beim Initial-SL ≈ risk_frac × Equity.
Ersetzt das All-in-auf-freie-Margin von `calc_lots`. Die freie Margin bleibt
Obergrenze (Deckel), damit Mini-Konten nicht über die Margin hinaus ordern.
Gibt 0.0 zurück, wenn Daten fehlen → Caller fällt auf `calc_lots` zurück.
"""
si, acc = mt5.symbol_info(sym), mt5.account_info()
if not si or not acc or not sl_distance or sl_distance <= 0:
return 0.0
step = si.volume_step or 0.01
tick_size = si.trade_tick_size or si.point
tick_value = si.trade_tick_value
if not tick_size or not tick_value:
return 0.0
val_per_price = tick_value / tick_size # € je 1.0 Preis je 1 Lot
equity = acc.equity or acc.balance or 0.0
if equity <= 0:
return 0.0
raw = (equity * risk_frac) / (sl_distance * val_per_price)
lots = (raw // step) * step
# Margin-Deckel: nie mehr als die freie Margin (× Buffer) zulässt
mpl = mt5.order_calc_margin(otype, sym, 1.0, price)
if mpl and mpl > 0:
max_aff = ((acc.margin_free * get_margin_buffer() / mpl) // step) * step
lots = min(lots, max_aff)
lots = min(round(lots, 4), si.volume_max)
# Ergibt die Risiko-Rechnung WENIGER als das Mindestlot, NICHT auf volume_min
# aufrunden (das überschritte still das gewollte Risiko) → 0.0, Caller lehnt ab
# (Fix 2026-07-19; vorher max(volume_min, …)).
if lots < si.volume_min:
return 0.0
return lots
def atr_value(sym: str, tf: int = SL_TF, period: int = 14) -> float | None:
"""ATR (Wilder-vereinfacht: Mittel der True Ranges) auf `tf`."""
r = mt5.copy_rates_from_pos(sym, tf, 0, period + 1)
if r is None or len(r) < period + 1:
return None
trs = []
for i in range(1, len(r)):
h, l, pc = float(r[i]["high"]), float(r[i]["low"]), float(r[i - 1]["close"])
trs.append(max(h - l, abs(h - pc), abs(l - pc)))
return sum(trs) / len(trs) if trs else None
def pivot_low(sym: str, max_above: float):
r = mt5.copy_rates_from_pos(sym, SL_TF, 0, SL_LOOKBACK)
if r is None or len(r) < 2 * SL_WINDOW + 1:
return None
lows = [float(x["low"]) for x in r]
for i in range(len(lows) - SL_WINDOW - 1, SL_WINDOW - 1, -1):
c = lows[i]
if (c < max_above
and c < min(lows[i - SL_WINDOW:i])
and c < min(lows[i + 1:i + 1 + SL_WINDOW])):
return c
return None
def pivot_high(sym: str, min_below: float):
r = mt5.copy_rates_from_pos(sym, SL_TF, 0, SL_LOOKBACK)
if r is None or len(r) < 2 * SL_WINDOW + 1:
return None
highs = [float(x["high"]) for x in r]
for i in range(len(highs) - SL_WINDOW - 1, SL_WINDOW - 1, -1):
c = highs[i]
if (c > min_below
and c > max(highs[i - SL_WINDOW:i])
and c > max(highs[i + 1:i + 1 + SL_WINDOW])):
return c
return None
+297
View File
@@ -0,0 +1,297 @@
"""
core/mt5data.py — MT5Data
===========================
Daten-Adapter: holt Ticks, Bars, Kontoinfo und Multi-Timeframe-Analyse aus MT5.
"""
from __future__ import annotations
import threading
import time
from datetime import datetime, timezone
from concurrent.futures import ThreadPoolExecutor
import MetaTrader5 as mt5
from core.config import (
SYMBOL_CANDIDATES, CHART_BARS, ANGLE_LR_BARS,
)
from core.mt5_utils import mt5_lock
from core.analysis import (calc_trend_angle, calc_rsi, calc_atr,
M15Analyzer, SRDetector)
from core.logger import get_logger
log_mt5 = get_logger("mt5")
class MT5Data:
def __init__(self):
self.symbol = None
self.bid = self.ask = self.spread = None
self.change = self.pct = self.day_high = self.day_low = None
self.balance = self.equity = None
self.currency = "USD"; self.error = None
self.trend_angle = 90.0
self.angles = {"M5": 90.0, "M15": 90.0, "M30": 90.0, "H1": 90.0}
self._angle_ema: dict = {} # geglättete Winkel (EMA α=0.4)
self.rsi_m15: float | None = None
self.atr_m15: float | None = None
self.server_time: datetime | None = None
self.tick_local_ts: float = 0.0
self.tick_server_ts: int = 0
self._slow_price_ts: float = 0.0 # letzter D1-/Konto-Fetch (Throttle)
self.sessions: dict = {}
self._lock = threading.Lock()
self._connected = False
self._bad_tick_streak: int = 0
self.BAD_TICK_LIMIT: int = 10
self.analyzer = None; self.sr = None
self._analyze_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="analyzer")
def connect(self, preferred_symbol: str | None = None) -> bool:
if not mt5.initialize():
with self._lock: self.error = f"MT5: {mt5.last_error()}"; return False
acc = mt5.account_info()
if not acc:
with self._lock: self.error = "MT5 nicht eingeloggt"; return False
pref = (preferred_symbol or "").strip()
candidates = ([pref] if pref else []) + \
[s.strip() for s in SYMBOL_CANDIDATES if s.strip() != pref]
for sym in candidates:
if not sym: continue
if mt5.symbol_info(sym) is not None:
mt5.symbol_select(sym, True)
with self._lock:
self.symbol = sym; self.currency = acc.currency
self._connected = True
self.analyzer = M15Analyzer(sym)
self.sr = SRDetector(sym)
log_mt5.info(f"Konto {acc.login} | {acc.currency} | Symbol: {sym}")
self._load_sessions(sym)
return True
with self._lock: self.error = "Kein WTI-/SpotCrude-Symbol"; return False
def switch_symbol(self, new_sym: str) -> tuple[bool, str]:
new_sym = (new_sym or "").strip()
if not new_sym:
return False, "Symbol-Name leer"
try:
with mt5_lock(timeout=10) as got:
if not got:
return False, "MT5-Lock belegt"
info = mt5.symbol_info(new_sym)
if info is None:
return False, f"Symbol '{new_sym}' nicht beim Broker"
if not mt5.symbol_select(new_sym, True):
return False, f"symbol_select('{new_sym}') fehlgeschlagen"
new_analyzer = M15Analyzer(new_sym)
new_sr = SRDetector(new_sym)
with self._lock:
self.symbol = new_sym; self.analyzer = new_analyzer; self.sr = new_sr
self.m15_bars = []; self.ema_fast = []; self.ema_slow = []
self.trend_angle = 90.0
self.angles = {"M5": 90.0, "M15": 90.0, "M30": 90.0, "H1": 90.0}
self.rsi_m15 = None; self.atr_m15 = None
self.coc = None; self.sma50_h1 = None; self.vwap = None
self.error = None; self._bad_tick_streak = 0
self._load_sessions(new_sym)
log_mt5.info(f"Symbol gewechselt: {new_sym}")
return True, f"Symbol jetzt: {new_sym}"
except Exception as e:
log_mt5.error(f"switch_symbol({new_sym}): {e}", exc_info=True)
return False, f"Fehler: {e}"
# Fallback-Sessionsplan für Rohöl (UTC) — greift wenn MT5 keine Sessions liefert.
# MonFr 00:0022:00, So ab 22:00. Broker-Zeiten können minimal abweichen.
_OIL_SESSIONS_UTC = {
0: [(79200, 86400)], # So 22:0024:00
1: [(0, 79200)], # Mo 00:0022:00
2: [(0, 79200)], # Di
3: [(0, 79200)], # Mi
4: [(0, 79200)], # Do
5: [(0, 79200)], # Fr 00:0022:00
6: [], # Sa geschlossen
}
def _load_sessions(self, sym: str):
sessions = {}
api_ok = False
for day in range(7):
try:
raw = mt5.symbol_info_sessions_trade(sym, day)
if raw is not None:
sessions[day] = [(int(s.from_), int(s.to)) for s in raw]
api_ok = True
else:
sessions[day] = []
except Exception:
sessions[day] = []
if not api_ok:
sessions = dict(self._OIL_SESSIONS_UTC)
log_mt5.debug("Sessions-API nicht verfügbar — Fallback auf Öl-Standard (UTC)")
with self._lock:
self.sessions = sessions
def reconnect(self) -> bool:
# ALLE MT5-Calls (account_info/shutdown/initialize) MÜSSEN unter dem
# globalen Lock laufen — sonst racet ein shutdown()/initialize() gegen
# copy_rates/positions_get der anderen Loops (MT5-Lib ist nicht
# thread-safe → Crash/Garbage). reconnect() wird stets OHNE gehaltenen
# Lock aufgerufen (aus fetch_price/fetch_trend vor deren with-Block).
with mt5_lock(timeout=10) as got:
if not got:
with self._lock: self.error = "Reconnect: MT5-Lock belegt"
return False
try:
if mt5.account_info():
with self._lock: self._connected = True; self.error = None
log_mt5.info("MT5 noch verbunden — kein Hard-Reconnect nötig")
return True
except Exception:
pass
log_mt5.info("MT5 Hard-Reconnect …")
try: mt5.shutdown()
except Exception: pass
if not mt5.initialize():
with self._lock:
self.error = f"Reconnect fehlgeschlagen: {mt5.last_error()}"
self._connected = False
return False
if not mt5.account_info():
with self._lock:
self.error = "MT5 nach Reconnect nicht eingeloggt"; self._connected = False
return False
with self._lock: self._connected = True; self.error = None
log_mt5.info("MT5-Reconnect erfolgreich")
return True
def fetch_price(self):
if not self._connected:
self.reconnect(); return
with mt5_lock() as got:
if not got: return
self._fetch_price_locked()
def _fetch_price_locked(self):
# Hot-Path (500 ms): nur den Tick lesen — minimale Lock-Haltezeit,
# damit Bid/Ask (und damit die live-P&L) auch unter Lock-Konkurrenz
# (Trailing-order_send, fetch_trend) frisch bleiben.
sym = self.symbol
tick = mt5.symbol_info_tick(sym)
if not tick or tick.bid <= 0:
with self._lock:
self._bad_tick_streak += 1; self.error = f"Kein Tick: {sym}"
if self._bad_tick_streak >= self.BAD_TICK_LIMIT:
self._connected = False; self._bad_tick_streak = 0
log_mt5.warning(f"{self.BAD_TICK_LIMIT} Bad-Ticks → reconnect")
return
bid = float(tick.bid); ask = float(tick.ask); mid = (bid + ask) / 2
spread = round(ask - bid, 5)
srv_time = datetime.fromtimestamp(tick.time, tz=timezone.utc)
# Cold-Path (alle ~2 s): D1-Bars (Change/Tageshoch/-tief) + Kontoinfo.
# Nicht P&L-kritisch → seltener holen, hält den Lock kürzer frei.
now = time.time()
do_slow = (now - self._slow_price_ts) > 2.0
prev = dh = dl = acc = None
if do_slow:
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_D1, 0, 2)
if bars is not None and len(bars) >= 2:
prev = float(bars[0]["close"])
dh = float(bars[1]["high"]); dl = float(bars[1]["low"])
acc = mt5.account_info()
self._slow_price_ts = now
with self._lock:
self._bad_tick_streak = 0
self.bid = bid; self.ask = ask; self.spread = spread
_prev_srv = self.server_time
if do_slow:
if prev:
self.change = mid - prev
self.pct = (self.change / prev * 100) if prev else None
self.day_high = dh if dh is not None else self.day_high
self.day_low = dl if dl is not None else self.day_low
if acc:
self.balance = float(acc.balance)
self.equity = float(acc.equity)
self.currency = acc.currency
prev_srv_ts = int(_prev_srv.timestamp()) if _prev_srv else 0
tick_advanced = int(tick.time) > prev_srv_ts
self.server_time = srv_time
self.tick_server_ts = int(tick.time) # echter Broker-Timestamp
if tick_advanced: self.tick_local_ts = now
self.error = None
def fetch_trend(self):
if not self._connected:
self.reconnect(); return
with mt5_lock() as got:
if not got: return
self._fetch_trend_locked()
def _fetch_trend_locked(self):
# Nur noch die tatsächlich konsumierten Werte berechnen:
# Trendwinkel (Trailing/Reversal), RSI/ATR (Auto-Trader/Logging),
# M15-Analyzer (Reversal) und S/R (Trailing). Die früheren
# ICT-/Chart-Berechnungen (Fib, BOS, FVG, OB, Ichimoku, VWAP, EMAs …)
# fütterten nur die entfernte Empfehlungs-Engine.
sym = self.symbol
needed = CHART_BARS + 20
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M15, 0, needed)
if bars is not None and len(bars) >= CHART_BARS:
closes = [float(b["close"]) for b in bars]
highs = [float(b["high"]) for b in bars]
lows = [float(b["low"]) for b in bars]
ang = calc_trend_angle(closes)
rsi = calc_rsi(closes, 14)
atr = calc_atr(highs, lows, closes, 14)
with self._lock:
self.trend_angle = ang; self.rsi_m15 = rsi; self.atr_m15 = atr
tf_map = [("M5", mt5.TIMEFRAME_M5, ANGLE_LR_BARS),
("M15", mt5.TIMEFRAME_M15, ANGLE_LR_BARS),
("M30", mt5.TIMEFRAME_M30, ANGLE_LR_BARS),
("H1", mt5.TIMEFRAME_H1, ANGLE_LR_BARS)]
raw_angles = {}
for label, tf, lr in tf_map:
b = mt5.copy_rates_from_pos(sym, tf, 0, lr + 5)
raw_angles[label] = calc_trend_angle([float(x["close"]) for x in b], lr) \
if b is not None and len(b) >= lr else 90.0
# EMA-Glättung α=0.4 — dämpft Tick-Rauschen ohne echte Trendwenden zu verzögern
alpha = 0.4
with self._lock:
for lbl, raw in raw_angles.items():
prev = self._angle_ema.get(lbl, raw)
self._angle_ema[lbl] = alpha * raw + (1 - alpha) * prev
self.angles = dict(self._angle_ema)
if self.analyzer:
_az = self.analyzer
# analyze() läuft im eigenen Thread → MUSS den globalen mt5_lock selbst
# nehmen (MT5-Lib ist nicht thread-safe; sonst Race gegen alle anderen
# MT5-Calls). Eigener Thread, kein Deadlock mit dem hier gehaltenen Lock.
def _locked_analyze(az=_az):
with mt5_lock(timeout=3) as got:
if got:
az.analyze()
self._analyze_executor.submit(_locked_analyze)
if self.sr: self.sr.detect() # inline unter gehaltenem Lock → ok
def snapshot(self) -> dict:
with self._lock:
d = dict(
symbol=self.symbol, bid=self.bid, ask=self.ask, spread=self.spread,
change=self.change, pct=self.pct, day_high=self.day_high, day_low=self.day_low,
balance=self.balance, equity=self.equity, currency=self.currency, error=self.error,
trend_angle=self.trend_angle,
angles=dict(self.angles), rsi_m15=self.rsi_m15, atr_m15=self.atr_m15,
server_time=self.server_time, tick_local_ts=self.tick_local_ts,
tick_server_ts=self.tick_server_ts,
sessions=dict(self.sessions),
)
rev, reasons = self.analyzer.snapshot() if self.analyzer else (None, [])
d["reversal"] = rev; d["reasons"] = reasons
d["sr"] = self.sr.snapshot() if self.sr else None
return d
def disconnect(self):
mt5.shutdown()
+842
View File
@@ -0,0 +1,842 @@
"""
core/news.py — News-Fetcher + Übersetzer
==========================================
Lädt RSS-Feeds zu WTI-Crude/Öl-News und übersetzt sie optional auf Deutsch.
• NewsTranslator — deep-translator (Google + MyMemory-Fallback)
• NewsFetcher — 4 RSS-Feeds, Sortierung nach Datum
Cache der Übersetzungen liegt in oil_widget_translations.json
(neben dem Hauptscript) und überlebt Neustarts.
"""
from __future__ import annotations
import json
import hashlib
import threading
import time
from pathlib import Path
from core.logger import get_logger
log_trans = get_logger("trans")
log_news = get_logger("news")
# Cache-Pfad neben dem Hauptscript
TRANSLATION_CACHE_FILE = Path(__file__).parent.parent / "oil_widget_translations.json"
TRANSLATION_CACHE_MAX = 1000 # FIFO-Trim ab 1200
TRANSLATION_TIMEOUT_S = 8
# ══════════════════════════════════════════════
# NEWS-ÜBERSETZUNG
# ══════════════════════════════════════════════
class NewsTranslator:
"""
Übersetzt englische Nachrichten-Titel via deep-translator (Google).
• Persistenter JSON-Cache (kein doppelter API-Call für gleiche Headlines)
• Background-Threading: UI bleibt responsiv
• Fallback-Provider (Google → MyMemory) falls primary fehlschlägt
• Komplett lokal abschaltbar via [translation] enabled = false
"""
def __init__(self, enabled: bool = True,
target: str = "de",
provider: str = "google",
cache_file: Path = TRANSLATION_CACHE_FILE):
self.enabled = enabled
self.target = target.strip().lower() or "de"
self.provider = provider.strip().lower() or "google"
self.cache_file = cache_file
self.cache = {}
self._cache_dirty = False
self._lock = threading.Lock()
self.error = None
self.api_ok = None
self._load_cache()
def _load_cache(self):
if not self.cache_file.exists():
return
try:
with open(self.cache_file, "r", encoding="utf-8") as f:
self.cache = json.load(f)
log_trans.info(f"Cache geladen: {len(self.cache)} Einträge")
except Exception as e:
log_trans.error(f"Cache-Lesefehler: {e}")
self.cache = {}
def _save_cache(self):
if not self._cache_dirty:
return
try:
if len(self.cache) > TRANSLATION_CACHE_MAX + 200:
items = list(self.cache.items())
self.cache = dict(items[-TRANSLATION_CACHE_MAX:])
with open(self.cache_file, "w", encoding="utf-8") as f:
json.dump(self.cache, f, ensure_ascii=False, indent=1)
self._cache_dirty = False
except Exception as e:
log_trans.error(f"Cache-Schreibfehler: {e}")
@staticmethod
def _hash(text: str) -> str:
return hashlib.sha1(text.encode("utf-8", errors="ignore")).hexdigest()[:16]
def _translate_one(self, text: str) -> str | None:
try:
from deep_translator import GoogleTranslator
except ImportError:
self.error = "pip install deep-translator"
return None
try:
if self.provider == "google":
return GoogleTranslator(source="auto", target=self.target).translate(text)
except Exception as e:
log_trans.warning(f"Google-Fehler: {e}")
try:
from deep_translator import MyMemoryTranslator
src_full = "en-GB"
tgt_full = f"{self.target}-{self.target.upper()}"
return MyMemoryTranslator(source=src_full, target=tgt_full).translate(text)
except Exception as e:
log_trans.warning(f"MyMemory-Fehler: {e}")
return None
def translate(self, text: str) -> str:
if not self.enabled or not text:
return text
h = self._hash(text)
with self._lock:
cached = self.cache.get(h)
if cached:
return cached
result = self._translate_one(text)
if result and result.strip() and result.lower() != text.lower():
with self._lock:
self.cache[h] = result
self._cache_dirty = True
self.api_ok = True
return result
if result is None:
self.api_ok = False
return text
def translate_batch(self, headlines: list) -> list:
if not self.enabled:
return headlines
out = []
for h in headlines:
new = dict(h)
orig = h.get("title", "")
translated = self.translate(orig)
if translated != orig:
new["title_original"] = orig
new["title"] = translated
out.append(new)
if self._cache_dirty:
self._save_cache()
return out
def shutdown(self):
self._save_cache()
# ══════════════════════════════════════════════
# NEWS-FETCHER
# ══════════════════════════════════════════════
class NewsFetcher:
"""
Holt aktuelle WTI-Crude-News sowie geopolitische / makroökonomische News
aus öffentlichen RSS-Feeds.
Energie-Feeds (WTI / Rohstoff-spezifisch):
OilPrice, EIA Press, Rigzone, ShaleMag, Offshore Energy,
Oil&Gas 360, Guardian Oil
Makro- / Geopolitik-Feeds (marktrelevant für WTI):
Al Jazeera, BBC Business, BBC World, MarketWatch, The Hill Energy,
DW World, FT Commodities, Middle East Eye, Hellenic Shipping News
"""
# ── Energie / Rohstoffe — WTI-fokussiert ─────────────────────────────
FEEDS_ENERGY = [
("OilPrice", "https://oilprice.com/rss/main"),
("EIA Press", "https://www.eia.gov/rss/press_rss.xml"),
("Rigzone", "https://www.rigzone.com/news/rss/rigzone_latest.aspx"),
("ShaleMag", "https://shalemag.com/feed/"),
("Offshore Energy", "https://www.offshore-energy.biz/feed/"),
("Oil&Gas 360", "https://www.oilandgas360.com/feed/"),
("Guardian Oil", "https://www.theguardian.com/business/oil/rss"),
]
# ── Makro / Geopolitik (marktrelevant für WTI) ───────────────────────
# Reuters-Feed wurde 2020 abgeschaltet — nicht mehr verwenden.
FEEDS_GEO = [
("Al Jazeera", "https://www.aljazeera.com/xml/rss/all.xml"),
("BBC Business", "https://feeds.bbci.co.uk/news/business/rss.xml"),
("BBC World", "https://feeds.bbci.co.uk/news/world/rss.xml"),
("MarketWatch", "https://feeds.marketwatch.com/marketwatch/marketpulse/"),
("The Hill", "https://thehill.com/policy/energy-environment/feed/"),
("DW World", "https://rss.dw.com/rdf/rss-en-world"),
("FT Commodities","https://www.ft.com/commodities?format=rss"),
("Middle East Eye","https://www.middleeasteye.net/rss"),
("Hellenic Ship.", "https://www.hellenicshippingnews.com/feed/"),
]
# Kombination beider Feed-Gruppen
FEEDS = FEEDS_ENERGY + FEEDS_GEO
# Schlüsselwörter zur Relevanz-Filterung (Geo-Feeds)
GEO_KEYWORDS = {
# Öl/Energie allgemein
"oil", "crude", "wti", "brent", "petroleum", "energy",
"opec", "iea", "eia", "barrel", "supply", "demand",
"refin", "pipeline", "tanker", "stockpile", "inventory", "stocks",
"spr", "gas", "fuel", "commodity", "commodities", "lng",
# Schifffahrt / Tankerrouten — direkt preisrelevant
"shipping", "vessel", "suez", "canal", "chokepoint",
"bab-el-mandeb", "vlcc", "freight", "cargo",
# WTI-spezifisch: US-Produktion & Infrastruktur
"cushing", "permian", "bakken", "eagle ford", "marcellus",
"keystone", "gulf of mexico", "nymex", "shale", "fracking",
"us oil", "us energy", "us crude", "us production",
"refinery capacity", "hurricane",
# Sanktionen / Geopolitik
"iran", "sanction", "embargo", "strait", "hormuz", "gulf",
# Akteure
"saudi", "russia", "moscow", "putin", "iraq",
"venezuela", "libya", "nigeria", "kuwait", "uae", "qatar",
"ukraine", "hamas", "houthi", "yemen", "israel", "hezbollah",
"lebanon", "syria", "china", "beijing", "iran",
# Ereignisse
"war", "attack", "ceasefire", "truce", "drone", "missile",
"embargo", "blockade", "strike", "shutdown", "outage",
"disruption", "force majeure", "summit", "deal", "agreement",
"tariff", "trade war", "recession", "inflation", "gdp",
}
# ── Vollständige Feed-Pools inkl. Alternativen (Reihenfolge = Priorität) ──
# validate_feeds() testet alle Feeds parallel und ersetzt ausgefallene
# primäre Feeds automatisch mit dem nächsten funktionierenden Pool-Eintrag.
_ENERGY_POOL = [
# Primär
("OilPrice", "https://oilprice.com/rss/main"),
("EIA Press", "https://www.eia.gov/rss/press_rss.xml"),
("Rigzone", "https://www.rigzone.com/news/rss/rigzone_latest.aspx"),
("ShaleMag", "https://shalemag.com/feed/"),
("Offshore Energy", "https://www.offshore-energy.biz/feed/"),
("Oil&Gas 360", "https://www.oilandgas360.com/feed/"),
("Guardian Oil", "https://www.theguardian.com/business/oil/rss"),
# Alternativen (automatischer Fallback)
("Energy Monitor", "https://www.energymonitor.ai/feed/"),
("Natural Gas Int.", "https://www.naturalgasintel.com/feed/"),
]
_GEO_POOL = [
# Primär
("Al Jazeera", "https://www.aljazeera.com/xml/rss/all.xml"),
("BBC Business", "https://feeds.bbci.co.uk/news/business/rss.xml"),
("BBC World", "https://feeds.bbci.co.uk/news/world/rss.xml"),
("MarketWatch", "https://feeds.marketwatch.com/marketwatch/marketpulse/"),
("The Hill", "https://thehill.com/policy/energy-environment/feed/"),
("DW World", "https://rss.dw.com/rdf/rss-en-world"),
("FT Commodities", "https://www.ft.com/commodities?format=rss"),
("Middle East Eye", "https://www.middleeasteye.net/rss"),
("Hellenic Ship.", "https://www.hellenicshippingnews.com/feed/"),
# Alternativen (automatischer Fallback)
("New Arab", "https://www.newarab.com/rss.xml"),
]
# WTI-Direktseite (HTML-Scraper, keine RSS)
OILPRICE_WTI_URL = "https://oilprice.com/futures/wti/"
# Globale Öl-Preistabelle (Breadth + WTI/Brent Spot)
OILPRICE_CHARTS_URL = "https://oilprice.com/oil-price-charts/"
# HTTP-Timeout pro Feed in Sekunden — verhindert dass langsame/tote
# Feeds den ganzen Fetch-Thread blockieren.
FETCH_TIMEOUT_S = 8
def __init__(self, translator: 'NewsTranslator | None' = None):
self.headlines = []
self.last_fetch = None
self.error = None
self.translator = translator
self.sentiment = {"score": 0.0, "n_bull": 0, "n_bear": 0, "samples": []}
self.price_data: dict | None = None # OilPrice Charts Breadth + Spot
# Pro-Feed Status: {source: {"ok": bool, "n": int, "msg": str}}
self.feed_status: dict = {}
# Validierte, aktive Feed-Listen (None = noch nicht validiert → Klassenvariable)
self._active_energy: list | None = None
self._active_geo: list | None = None
self._lock = threading.Lock()
def _is_geo_relevant(self, title: str) -> bool:
"""Gibt True zurück, wenn ein Geo-Feed-Titel marktrelevante Keywords enthält."""
low = title.lower()
return any(kw in low for kw in self.GEO_KEYWORDS)
# ══════════════════════════════════════════════
# FEED-VALIDIERUNG (einmalig beim Start)
# ══════════════════════════════════════════════
def _test_url(self, source: str, url: str) -> tuple[bool, int, str]:
"""Schneller Feed-Test ohne feed_status zu verändern. Gibt (ok, n_entries, msg) zurück."""
try:
import feedparser, requests
except ImportError:
return False, 0, "missing: feedparser/requests"
try:
resp = requests.get(
url,
timeout=(2, 6), # kurze Timeouts für schnelle Validierung
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 OilWidget/1.0",
"Accept": "application/rss+xml, application/xml, text/xml, */*",
},
)
if resp.status_code != 200:
return False, 0, f"HTTP {resp.status_code}"
feed = feedparser.parse(resp.content)
n = len(feed.entries)
if n == 0:
return False, 0, "0 Einträge"
return True, n, "ok"
except Exception as e:
return False, 0, str(e)[:60]
def validate_feeds(self) -> dict:
"""
Testet alle Feeds aus _ENERGY_POOL / _GEO_POOL parallel.
Ausgefallene Primär-Feeds werden automatisch durch den nächsten
funktionierenden Pool-Eintrag ersetzt.
Ergebnis wird in self._active_energy / self._active_geo gespeichert.
Aufruf: einmalig beim Start in einem Daemon-Thread.
"""
import concurrent.futures
all_feeds = list({u: (s, u) for s, u in
self._ENERGY_POOL + self._GEO_POOL}.values())
log_news.info(f"Feed-Validierung: teste {len(all_feeds)} Feeds …")
# Parallel testen
test_results: dict[str, tuple[bool, int, str]] = {} # url → (ok, n, msg)
with concurrent.futures.ThreadPoolExecutor(max_workers=12) as ex:
fut_map = {ex.submit(self._test_url, s, u): (s, u) for s, u in all_feeds}
try:
for fut in concurrent.futures.as_completed(fut_map, timeout=25):
src, url = fut_map[fut]
try:
ok, n, msg = fut.result()
except Exception as e:
ok, n, msg = False, 0, str(e)[:50]
test_results[url] = (ok, n, msg)
status = "OK" if ok else "FAIL"
log_news.debug(f" [{status}] {src}: {msg} ({n} Einträge)")
except concurrent.futures.TimeoutError:
log_news.warning("Feed-Validierung: Timeout nach 25 s")
def build_active(primary: list, pool: list) -> tuple[list, list]:
"""
Gibt (aktive_feeds, ersetzungen) zurück.
Primäre Feeds haben Vorrang; ausgefallene werden mit dem
nächsten noch nicht verwendeten Pool-Feed ersetzt.
"""
primary_urls = {u for _, u in primary}
alternatives = [(s, u) for s, u in pool if u not in primary_urls]
active = []
used_urls: set[str] = set()
replacements = []
for src, url in primary:
ok, n, msg = test_results.get(url, (False, 0, "nicht getestet"))
if ok and n > 0:
active.append((src, url))
used_urls.add(url)
else:
log_news.warning(f"Feed ausgefallen: {src} [{msg}] → suche Ersatz")
replaced = False
for alt_src, alt_url in alternatives:
if alt_url in used_urls:
continue
alt_ok, alt_n, alt_msg = test_results.get(alt_url, (False, 0, "nicht getestet"))
if alt_ok and alt_n > 0:
active.append((alt_src, alt_url))
used_urls.add(alt_url)
replacements.append((src, alt_src))
log_news.info(f" → Ersetzt: {src}{alt_src}")
replaced = True
break
if not replaced:
log_news.warning(f" → Kein Ersatz verfügbar für {src}")
return active, replacements
active_e, rep_e = build_active(self.FEEDS_ENERGY, self._ENERGY_POOL)
active_g, rep_g = build_active(self.FEEDS_GEO, self._GEO_POOL)
with self._lock:
self._active_energy = active_e
self._active_geo = active_g
total_ok = sum(1 for ok, _, _ in test_results.values() if ok)
total_fail = len(test_results) - total_ok
log_news.info(
f"Feed-Validierung abgeschlossen: {total_ok} OK / {total_fail} ausgefallen "
f"| Energie: {len(active_e)} aktiv ({len(rep_e)} ersetzt) "
f"| Geo: {len(active_g)} aktiv ({len(rep_g)} ersetzt)"
)
return {
"energy_active": active_e,
"geo_active": active_g,
"replacements": rep_e + rep_g,
"n_ok": total_ok,
"n_fail": total_fail,
}
def _fetch_oilprice_wti_scrape(self) -> list:
"""
Scrapet https://oilprice.com/futures/wti/ nach WTI-spezifischen Headlines.
Nutzt BeautifulSoup wenn verfügbar, sonst Regex-Fallback.
Gibt bis zu 8 Einträge im selben Format wie RSS-Feeds zurück.
"""
try:
import requests
except ImportError:
return []
try:
resp = requests.get(
self.OILPRICE_WTI_URL,
timeout=(3, self.FETCH_TIMEOUT_S),
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/120.0 OilWidget/1.0",
"Accept": "text/html,application/xhtml+xml,*/*",
"Accept-Language": "en-US,en;q=0.9",
},
)
except Exception as e:
log_news.warning(f"OilPrice WTI scrape: {e}")
self.feed_status["OilPrice WTI"] = {"ok": False, "n": 0, "msg": str(e)[:60]}
return []
if resp.status_code != 200:
log_news.warning(f"OilPrice WTI: HTTP {resp.status_code}")
self.feed_status["OilPrice WTI"] = {
"ok": False, "n": 0, "msg": f"HTTP {resp.status_code}"}
return []
items = []
now = time.time()
seen = set()
# Artikel-Pfade auf OilPrice.com: /Energy/... und /Latest-Energy-News/...
_ARTICLE_PATHS = ("/Energy/", "/Latest-Energy-News/")
try:
from bs4 import BeautifulSoup
soup = BeautifulSoup(resp.content, "html.parser")
for tag in soup.find_all("a", href=True):
href = tag.get("href", "")
title = tag.get_text(strip=True)
# Nur Artikel-Pfade, keine Navigation/Werbung
if not any(p in href for p in _ARTICLE_PATHS):
continue
if len(title) < 25 or title in seen:
continue
if not href.startswith("http"):
href = ("https://oilprice.com" + href
if href.startswith("/") else "")
if not href:
continue
seen.add(title)
items.append({
"title": title,
"link": href,
"source": "OilPrice WTI",
"ts": now,
"category": "energy",
})
if len(items) >= 8:
break
except ImportError:
# Regex-Fallback: kein beautifulsoup4 installiert
import re
pat = re.compile(
r'<a[^>]+href="(https?://oilprice\.com/'
r'(?:Energy|Latest-Energy-News)/[^"#?]{10,})"[^>]*>'
r'\s*([^<\n]{25,200})\s*</a>',
re.IGNORECASE | re.DOTALL,
)
for href, raw in pat.findall(resp.text):
title = re.sub(r'\s+', ' ', raw).strip()
if not title or title in seen:
continue
seen.add(title)
items.append({
"title": title,
"link": href,
"source": "OilPrice WTI",
"ts": now,
"category": "energy",
})
if len(items) >= 8:
break
if not items:
log_news.info("OilPrice WTI: BeautifulSoup nicht installiert "
"(pip install beautifulsoup4 lxml) — Regex-Fallback aktiv")
self.feed_status["OilPrice WTI"] = {
"ok": len(items) > 0,
"n": len(items),
"msg": "scrape ok" if items else "0 Headlines",
}
log_news.info(f"OilPrice WTI scrape: {len(items)} Headlines")
return items
def _fetch_oilprice_charts(self) -> dict | None:
"""
Scrapet https://oilprice.com/oil-price-charts/ und extrahiert:
• WTI Crude und Brent Crude: Preis, absolute Änderung, %Änderung
• Market Breadth aus Tabelle 'Futures & Indexes':
Anteil der Öl-Benchmarks mit positivem Tageschange
• breadth_signal: -1.0 (alle runter) … +1.0 (alle rauf)
Breadth > +0.5 → bullisher Marktkontext
Breadth < -0.5 → bärischer Marktkontext
"""
try:
import requests
except ImportError:
return None
try:
resp = requests.get(
self.OILPRICE_CHARTS_URL,
timeout=(3, self.FETCH_TIMEOUT_S),
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/120.0 OilWidget/1.0",
"Accept": "text/html,application/xhtml+xml,*/*",
"Accept-Language": "en-US,en;q=0.9",
},
)
except Exception as e:
log_news.warning(f"OilPrice Charts fetch: {e}")
return None
if resp.status_code != 200:
log_news.warning(f"OilPrice Charts: HTTP {resp.status_code}")
return None
try:
from bs4 import BeautifulSoup
except ImportError:
log_news.warning("OilPrice Charts: BeautifulSoup fehlt (pip install beautifulsoup4)")
return None
try:
soup = BeautifulSoup(resp.content, "html.parser")
tables = soup.find_all("table", class_="oilprices__table")
if not tables:
log_news.warning("OilPrice Charts: Tabelle nicht gefunden")
return None
t0 = tables[0] # "Futures & Indexes"
n_up = n_down = 0
wti = brent = None
for row in t0.find_all("tr"):
cells = row.find_all(["th", "td"])
if len(cells) < 4:
continue
name_cell = cells[1]
price_cell = cells[2]
change_cell = cells[3]
pct_cell = cells[4] if len(cells) > 4 else None
name = name_cell.get_text(strip=True)
try:
price = float(price_cell.get_text(strip=True).replace(",", ""))
except ValueError:
continue
change_text = change_cell.get_text(strip=True)
change_classes = change_cell.get("class", [])
try:
change = float(change_text.replace(",", ""))
except ValueError:
change = 0.0
is_up = any("up" in c for c in change_classes)
is_down = any("down" in c for c in change_classes)
if is_up:
n_up += 1
elif is_down:
n_down += 1
if name in ("WTI Crude", "Brent Crude"):
pct = 0.0
if pct_cell:
raw_pct = pct_cell.get_text(strip=True).split("(")[0]
try:
pct = float(raw_pct.replace("%", "").replace(",", ""))
except ValueError:
pass
entry = {"price": price, "change": change, "pct": pct}
if name == "WTI Crude":
wti = entry
else:
brent = entry
n_total = n_up + n_down
breadth_pct = round(n_up / n_total * 100, 1) if n_total else 50.0
breadth_signal = round((n_up - n_down) / n_total, 3) if n_total else 0.0
result = {
"wti": wti,
"brent": brent,
"n_up": n_up,
"n_down": n_down,
"n_total": n_total,
"breadth_pct": breadth_pct, # % der Futures die gestiegen sind
"breadth_signal": breadth_signal, # -1..+1
}
wti_str = f"WTI={wti['price']:.2f} ({wti['pct']:+.2f}%)" if wti else "WTI=?"
brent_str = f"Brent={brent['price']:.2f} ({brent['pct']:+.2f}%)" if brent else "Brent=?"
log_news.info(
f"OilPrice Charts: {wti_str} {brent_str} "
f"Breadth {breadth_pct:.0f}% ({n_up}up/{n_down}dn) signal={breadth_signal:+.2f}"
)
return result
except Exception as e:
log_news.warning(f"OilPrice Charts parse: {e}", exc_info=True)
return None
def price_data_snapshot(self) -> dict | None:
"""Thread-safe Kopie der letzten Preisdaten (OilPrice Charts)."""
with self._lock:
return dict(self.price_data) if self.price_data else None
def _load_feed(self, source: str, url: str):
"""
Lädt eine einzelne RSS-URL mit hardem HTTP-Timeout.
Liefert die feedparser-Feed oder None bei Fehler.
Setzt feed_status[source] für spätere Diagnose.
"""
try:
import feedparser
import requests
except ImportError as e:
self.feed_status[source] = {"ok": False, "n": 0, "msg": f"missing: {e}"}
return None
try:
resp = requests.get(
url,
timeout=(3, self.FETCH_TIMEOUT_S),
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 OilWidget/1.0",
"Accept": "application/rss+xml, application/xml, text/xml, */*",
},
)
if resp.status_code != 200:
self.feed_status[source] = {
"ok": False, "n": 0,
"msg": f"HTTP {resp.status_code}",
}
log_news.warning(f"{source}: HTTP {resp.status_code}")
return None
feed = feedparser.parse(resp.content)
if feed.bozo and not feed.entries:
# bozo=1 mit Entries ist meist nur "namespace warning" → OK
msg = str(feed.bozo_exception)[:80] if hasattr(feed, "bozo_exception") else "parse error"
self.feed_status[source] = {"ok": False, "n": 0, "msg": msg}
log_news.warning(f"{source}: {msg}")
return None
n = len(feed.entries)
self.feed_status[source] = {"ok": True, "n": n, "msg": "ok"}
return feed
except Exception as e:
self.feed_status[source] = {"ok": False, "n": 0, "msg": str(e)[:80]}
log_news.warning(f"{source}: {e}")
return None
def fetch(self):
try:
import feedparser # nur für Verfügbarkeits-Check
except ImportError:
with self._lock:
self.error = "pip install feedparser requests"
return
# Per-Feed Status für diesen Run leeren
self.feed_status = {}
energy_items = []
geo_items = []
# ── OilPrice WTI-Direktseite (Scraper, höchste Priorität) ──
wti_items = self._fetch_oilprice_wti_scrape()
energy_items.extend(wti_items)
# ── OilPrice Charts — Preis-Breadth ──
charts_data = self._fetch_oilprice_charts()
# Validierte Listen verwenden (nach validate_feeds()), sonst Klassen-Defaults
with self._lock:
energy_feeds = self._active_energy if self._active_energy is not None else self.FEEDS_ENERGY
geo_feeds = self._active_geo if self._active_geo is not None else self.FEEDS_GEO
# ── Energie-Feeds: bis zu 5 Einträge je Feed, keine Filterung ──
for source, url in energy_feeds:
feed = self._load_feed(source, url)
if feed is None:
continue
for entry in feed.entries[:5]:
pub = entry.get("published_parsed") or time.gmtime()
energy_items.append({
"title": entry.get("title", "(no title)").strip(),
"link": entry.get("link", ""),
"source": source,
"ts": time.mktime(pub),
"category": "energy",
})
# ── Geo-Feeds: bis zu 3 Einträge je Feed, nur relevante Titel ──
for source, url in geo_feeds:
feed = self._load_feed(source, url)
if feed is None:
continue
count = 0
for entry in feed.entries:
if count >= 3:
break
title = entry.get("title", "").strip()
if not self._is_geo_relevant(title):
continue
pub = entry.get("published_parsed") or time.gmtime()
geo_items.append({
"title": title,
"link": entry.get("link", ""),
"source": source,
"ts": time.mktime(pub),
"category": "geo",
})
count += 1
# Diagnose-Zeilen ins Log
ok_count = sum(1 for s in self.feed_status.values() if s["ok"])
fail_count = len(self.feed_status) - ok_count
log_news.info(
f"Feed-Status: {ok_count} OK, {fail_count} fehlgeschlagen"
)
for source, st in self.feed_status.items():
if st["ok"]:
log_news.debug(f"{source}: {st['n']} Einträge")
else:
log_news.info(f"{source}: {st['msg']}")
# ── Zusammenführen: WTI-Scraper + RSS Energie + Geo ──
# WTI-Scraper-Items haben ts=now → sortieren immer an erste Stelle wenn aktuell
energy_items.sort(key=lambda x: x["ts"], reverse=True)
geo_items.sort(key=lambda x: x["ts"], reverse=True)
# WTI-Scraper bekommt bis zu 4 Slots im Energy-Bucket (von 7 gesamt)
wti_out = [i for i in energy_items if i.get("source") == "OilPrice WTI"][:4]
rss_energy = [i for i in energy_items if i.get("source") != "OilPrice WTI"][:3]
items = wti_out + rss_energy + geo_items[:4]
items.sort(key=lambda x: x["ts"], reverse=True)
items = items[:10]
log_news.info(
f"→ WTI-Scraper:{len(wti_out)} + Energie-RSS:{len(rss_energy)} + "
f"Geo:{len(geo_items[:4])} = {len(items)} Headlines"
)
if self.translator and self.translator.enabled:
try:
items = self.translator.translate_batch(items)
except Exception as e:
log_news.warning(f"Übersetzung fehlgeschlagen: {e}")
# Sentiment auf den (ggf. übersetzten) Headlines berechnen.
# calc_news_sentiment() greift bevorzugt auf title_original (englisch) zurück.
try:
from core.analysis import calc_news_sentiment
sentiment = calc_news_sentiment(items)
except Exception as e:
log_news.warning(f"Sentiment-Berechnung fehlgeschlagen: {e}")
sentiment = {"score": 0.0, "n_bull": 0, "n_bear": 0, "samples": []}
# ── Breadth-Signal in Sentiment einblenden (Gewicht 25%) ──────────────
# Breadth misst ob die Mehrheit aller Öl-Benchmarks steigt/fällt.
# Nur bei starkem Signal (|breadth| > 0.4) und ohne starke News-Gegenmeinung.
if charts_data:
bs = charts_data.get("breadth_signal", 0.0)
if abs(bs) > 0.4:
raw_score = sentiment["score"]
blended = round(raw_score * 0.75 + bs * 0.25, 3)
blended = max(-1.0, min(1.0, blended))
sentiment = dict(sentiment)
sentiment["score"] = blended
sentiment["breadth_signal"] = bs
sentiment["breadth_pct"] = charts_data.get("breadth_pct", 50.0)
log_news.info(
f"Breadth {bs:+.2f} → Sentiment {raw_score:+.2f}{blended:+.2f}"
)
with self._lock:
self.headlines = items
self.last_fetch = time.time()
self.error = None if items else "Keine News-Feeds erreichbar"
self.sentiment = sentiment
self.price_data = charts_data
log_news.info(f"News-Sentiment: {sentiment['score']:+.2f} "
f"(bull={sentiment['n_bull']}, bear={sentiment['n_bear']})")
def snapshot(self):
with self._lock:
return list(self.headlines), self.last_fetch, self.error
def sentiment_snapshot(self) -> dict:
"""Thread-safe Kopie des letzten Sentiment-Snapshots."""
with self._lock:
return dict(self.sentiment)
def storm_state(self) -> dict:
"""News-Sturm-Indikator (REINE ANZEIGE, kein Signal): Headline-Rate der
letzten Stunde vs. Basisrate des vorhandenen Feed-Fensters. Viel frische
Öl-/Geo-Schlagzeilen auf einmal = Ereignis läuft → Vorsicht (Vola).
normal · elevated (≥3 frisch & ≥2× Basis) · storm (≥5 frisch & ≥3× Basis)."""
with self._lock:
items = [h for h in self.headlines if h.get("ts")]
now = time.time()
if not items:
return {"level": "normal", "fresh": 0, "base_per_h": 0.0}
fresh = sum(1 for h in items if now - h["ts"] <= 3600)
# Basis-Fenster min. 6 h verankern — sonst bläht ein frischer Burst die
# Basisrate selbst auf und der Sturm erkennt sich nicht (Selbst-Normierung).
span_h = min(48.0, max(6.0, (now - min(h["ts"] for h in items)) / 3600))
base = len(items) / span_h # Ø Headlines/Stunde im Fenster
level = "normal"
if fresh >= 5 and fresh >= 3 * base:
level = "storm"
elif fresh >= 3 and fresh >= 2 * base:
level = "elevated"
return {"level": level, "fresh": fresh, "base_per_h": round(base, 1)}
+112
View File
@@ -0,0 +1,112 @@
"""
core/notify.py — Telegram-Benachrichtigungen
=============================================
Sendet Trade-Abschlüsse als Telegram-Nachricht.
Kein externes Package nötig — nur urllib aus der Standardbibliothek.
"""
from __future__ import annotations
import json
import threading
import urllib.request
from core.logger import get_logger
log_notify = get_logger("notify")
# Setup-Namen für lesbare Telegram-Nachricht
_SETUP_LABELS = {
"TREND_PULLBACK_LONG": "Pullback ▲",
"TREND_PULLBACK_LONG_AT_SUPPORT": "Pullback ▲ @ Support",
"TREND_PULLBACK_SHORT": "Pullback ▼",
"TREND_PULLBACK_SHORT_AT_RESISTANCE": "Pullback ▼ @ Resistance",
"TREND_CONTINUATION_LONG": "Continuation ▲",
"TREND_CONTINUATION_SHORT": "Continuation ▼",
"BREAKOUT_LONG": "Breakout ▲",
"BREAKOUT_SHORT": "Breakout ▼",
"MEAN_REVERT_LONG": "Mean-Revert ▲",
"MEAN_REVERT_SHORT": "Mean-Revert ▼",
"DEAD_CAT_BOUNCE_SHORT": "Dead Cat Bounce ▼",
}
_CLOSED_BY_LABEL = {
"sl": "SL ❌",
"tp": "TP ✅",
"manual": "Manuell 🖐",
"emergency": "Emergency 🚨",
"unknown": "Unbekannt ⚠️",
}
def send_telegram(message: str, token: str, chat_id: str) -> None:
"""Sendet eine Telegram-Nachricht asynchron (blockiert nicht)."""
def _send():
try:
url = f"https://api.telegram.org/bot{token}/sendMessage"
body = json.dumps({
"chat_id": chat_id,
"text": message,
"parse_mode": "HTML",
}).encode("utf-8")
req = urllib.request.Request(
url, data=body,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read())
if not result.get("ok"):
log_notify.warning(f"Telegram API Fehler: {result}")
else:
log_notify.debug("Telegram-Nachricht gesendet")
except Exception as e:
log_notify.warning(f"Telegram-Send fehlgeschlagen: {e}")
threading.Thread(target=_send, daemon=True).start()
def build_trade_open_message(
direction: str,
symbol: str | None = None,
lots: float | None = None,
entry_price: float | None = None,
setup: str | None = None,
entry_ts: int | None = None,
) -> str:
"""Formatiert die Telegram-Nachricht für einen Trade-Einstieg."""
import datetime
dir_str = "LONG" if (direction or "").upper() in ("BUY", "LONG") else "SHORT"
emoji = "\U0001f7e2" if dir_str == "LONG" else "\U0001f534"
setup_str = _SETUP_LABELS.get(setup or "", setup or "")
sym_str = f" · {symbol}" if symbol else ""
import time as _time
time_str = (_time.strftime("%d.%m %H:%M", _time.localtime(entry_ts))
if entry_ts else _time.strftime("%d.%m %H:%M"))
lines = [f"{emoji} <b>{dir_str}{sym_str}</b>"]
if entry_price:
lines.append(f"Einstieg: {entry_price:.5g}"
+ (f" · {lots} Lots" if lots else ""))
if setup:
lines.append(f"Setup: {setup_str}")
lines.append(f"Zeit: {time_str}")
return "\n".join(lines)
def build_trade_close_message(
direction: str,
pnl: float,
commission: float,
closed_by: str,
setup: str | None,
exit_ts: int,
symbol: str | None = None,
) -> str:
"""Formatiert die Telegram-Nachricht für einen Trade-Abschluss."""
import datetime
pnl_net = pnl + (commission or 0.0)
win = pnl_net >= 0
sign = "+" if win else ""
import time as _time
ts_str = (_time.strftime("%d.%m", _time.localtime(exit_ts)) if win
else _time.strftime("%H:%M", _time.localtime(exit_ts)))
sym_str = f"{symbol} " if symbol else ""
return f"{sym_str}{sign}{pnl_net:.2f} {ts_str}"
+247
View File
@@ -0,0 +1,247 @@
"""
core/structure.py — Marktstruktur-Erkennung (ANZEIGE, kein Signal)
==================================================================
Erkennt aus den M30-Bars die klassische Price-Action-Struktur und liefert sie
als Kontext fürs Dashboard:
• Swing-Folge HH / HL / LH / LL (Pivot-Hochs/-Tiefs, jeweils vs. Vorgänger)
• letzter BOS (Break of Structure: Richtung + gebrochenes Level)
• Regressionskanal (Richtung + Position des Kurses im Kanal 0..1)
• Gesamt-Struktur up / down / range
REINE ANZEIGE — wie TF-Ampel/Squeeze/Bounce: KEIN Trade-Trigger, KEIN Verdict-
Gewicht. Die handelbaren Varianten sind separat gemessen & verworfen:
BOS-Entry ≈ Momentum-Continuation (`backtest_momentum.py`, regime-abhängig),
Kanal-/Zonen-Bounce ≈ P(break)-Level-Bounce (6× belegt: Münzwurf am Extrem).
Deshalb malt dieses Modul KEINE Richtung/Prognose — es beschreibt nur den Ist-Zustand.
Thread-sicher: refresh_market(sym) holt die Bars unter mt5_lock (~30 s gedrosselt),
snapshot() liefert den letzten Stand ohne MT5-Call.
"""
from __future__ import annotations
import threading
import time
import MetaTrader5 as mt5
from core.mt5_utils import mt5_lock
from core.logger import get_logger
log = get_logger("structure")
_TF = mt5.TIMEFRAME_M30 # Struktur auf M30 (klare Swings, wie der Referenz-Chart)
_N_BARS = 220
_PIVOT_K = 3 # Swing-Pivot-Fenster (k Bars je Seite)
_REG_N = 60 # Regressionsfenster für den Kanal (~30 h auf M30)
_SLOPE_DEAD = 0.015 # |Steigung/Bar| < dead×ATR → Kanal "flat"
_MAX_SWINGS = 6 # so viele letzte Swings ausgeben
_REFRESH_S = 30.0 # Drossel (Struktur ändert sich langsam, spart Lock-Zeit)
def _atr(highs, lows, closes, p=14):
trs = []
for i in range(1, len(closes)):
trs.append(max(highs[i]-lows[i], abs(highs[i]-closes[i-1]), abs(lows[i]-closes[i-1])))
return (sum(trs[-p:]) / min(len(trs), p)) if trs else None
def _pivots(highs, lows, k):
"""Alternierende Swing-Punkte → Liste (index, price, kind) mit kind 'H'/'L'.
Swing-High bei i: höchster Bar im Fenster [i-k .. i+k] und lokales Maximum."""
n = len(highs)
raw = []
for i in range(k, n - k):
win_hi = max(highs[i-k:i+k+1]); win_lo = min(lows[i-k:i+k+1])
if highs[i] == win_hi and highs[i] > highs[i-1] and highs[i] >= highs[i+1]:
raw.append((i, highs[i], "H"))
elif lows[i] == win_lo and lows[i] < lows[i-1] and lows[i] <= lows[i+1]:
raw.append((i, lows[i], "L"))
# Alternierung erzwingen: zwei gleiche Typen in Folge → den extremeren behalten
out = []
for p in raw:
if out and out[-1][2] == p[2]:
if (p[2] == "H" and p[1] > out[-1][1]) or (p[2] == "L" and p[1] < out[-1][1]):
out[-1] = p
else:
out.append(p)
return out
def _classify(pivots):
"""Swing-Folge als HH/HL/LH/LL (vs. jeweils vorheriges High bzw. Low)."""
labels = []
last_h = last_l = None
for idx, price, kind in pivots:
if kind == "H":
lab = ("HH" if (last_h is not None and price > last_h)
else "LH" if last_h is not None else "H")
last_h = price
else:
lab = ("HL" if (last_l is not None and price > last_l)
else "LL" if last_l is not None else "L")
last_l = price
labels.append({"type": lab, "price": round(price, 3), "idx": idx})
return labels
def _trend_state(labels):
recent = [l["type"] for l in labels[-4:]]
ups = sum(1 for t in recent if t in ("HH", "HL"))
dns = sum(1 for t in recent if t in ("LH", "LL"))
if ups >= 3 and ups > dns:
return "up"
if dns >= 3 and dns > ups:
return "down"
return "range"
def _last_bos(labels, n_bars):
"""Letzter Break of Structure: jüngstes HH (bullisch, Vorlauf-Hoch gebrochen)
bzw. LL (bärisch). Level = das gebrochene vorige Extrem; bars_ago aus dem Index."""
prev_h = prev_l = None
bos = None
for l in labels:
if l["type"] in ("HH", "LH"):
if l["type"] == "HH" and prev_h is not None:
bos = {"dir": "up", "level": prev_h, "idx": l["idx"]}
prev_h = l["price"]
else:
if l["type"] == "LL" and prev_l is not None:
bos = {"dir": "down", "level": prev_l, "idx": l["idx"]}
prev_l = l["price"]
if bos:
bos["bars_ago"] = max(0, (n_bars - 1) - bos.pop("idx"))
bos["level"] = round(bos["level"], 3)
return bos
def _channel(closes, atr):
N = min(_REG_N, len(closes))
if N < 5:
return None
ys = closes[-N:]
mx = (N - 1) / 2.0
my = sum(ys) / N
sxx = sum((x - mx) ** 2 for x in range(N))
sxy = sum((x - mx) * (ys[x] - my) for x in range(N))
slope = sxy / sxx if sxx else 0.0
intercept = my - slope * mx
resid = [ys[x] - (slope * x + intercept) for x in range(N)]
up_off, lo_off = max(resid), min(resid)
last_x = N - 1
mid = slope * last_x + intercept
upper, lower = mid + up_off, mid + lo_off
width = upper - lower
pos = (ys[-1] - lower) / width if width > 0 else 0.5
if atr and abs(slope) < _SLOPE_DEAD * atr:
d = "flat"
else:
d = "up" if slope > 0 else "down"
return {"dir": d, "pos": round(max(0.0, min(1.0, pos)), 2),
"upper": round(upper, 3), "lower": round(lower, 3), "mid": round(mid, 3),
"slope_atr": round(slope / atr, 3) if atr else None}
def _channel_anchors(closes, times, atr):
"""Kanal als 2 Ankerpunkte je Linie (Fensterstart + letzter abgeschl. Bar) mit
BROKER-Zeiten — für die MQL5-Bridge (OBJ_TREND, nach rechts verlängert).
closes/times = abgeschlossene Bars (gleich lang). Gibt {t1,t2,dir,upper,mid,lower}."""
N = min(_REG_N, len(closes))
if N < 5 or len(times) < N:
return None
seg = closes[-N:]; tt = times[-N:]
mx = (N - 1) / 2.0; my = sum(seg) / N
sxx = sum((x - mx) ** 2 for x in range(N))
sxy = sum((x - mx) * (seg[x] - my) for x in range(N))
b = sxy / sxx if sxx else 0.0
a = my - b * mx
resid = [seg[x] - (a + b * x) for x in range(N)]
up_off, lo_off = max(resid), min(resid)
m1 = a; m2 = a + b * (N - 1)
d = "flat" if (atr and abs(b) < _SLOPE_DEAD * atr) else ("up" if b > 0 else "down")
return {"t1": int(tt[0]), "t2": int(tt[-1]), "dir": d,
"upper": [round(m1 + up_off, 3), round(m2 + up_off, 3)],
"mid": [round(m1, 3), round(m2, 3)],
"lower": [round(m1 + lo_off, 3), round(m2 + lo_off, 3)]}
def channel_series(closes, atr, k):
"""Regressionskanal (mid/upper/lower) als Arrays der LETZTEN k Bars fürs
Chart-Overlay — Regression über die letzten _REG_N ABGESCHLOSSENEN Bars,
linear über alle k Bars extrapoliert (volle Chart-Breite). closes = alle
Closes (letzter = offener Bar). Gibt {dir, mid[], upper[], lower[]} zurück
(jeweils Länge k, deckungsgleich mit den zurückgelieferten Bars) oder None."""
if k < 2 or len(closes) < 6:
return None
cc = closes[:-1] # nur abgeschlossene Bars (wie die Struktur)
N = min(_REG_N, len(cc))
if N < 5:
return None
seg = cc[-N:]
mx = (N - 1) / 2.0
my = sum(seg) / N
sxx = sum((x - mx) ** 2 for x in range(N))
sxy = sum((x - mx) * (seg[x] - my) for x in range(N))
b = sxy / sxx if sxx else 0.0
a = my - b * mx # Preis bei x=0 (Fensterstart)
resid = [seg[x] - (a + b * x) for x in range(N)]
up_off, lo_off = max(resid), min(resid)
x0 = len(cc) - N # cc-Index von x=0
base = len(closes) - k # closes-Index des ersten Ausgabe-Bars
mid, up, lo = [], [], []
for j in range(k):
x = (base + j) - x0 # x relativ zum Fensterstart (extrapoliert)
m = a + b * x
mid.append(round(m, 3)); up.append(round(m + up_off, 3)); lo.append(round(m + lo_off, 3))
d = "flat" if (atr and abs(b) < _SLOPE_DEAD * atr) else ("up" if b > 0 else "down")
return {"dir": d, "mid": mid, "upper": up, "lower": lo}
class MarketStructure:
def __init__(self):
self._snap: dict = {"trend": None, "swings": [], "last_swing": None,
"bos": None, "channel": None, "tf": "M30", "error": None}
self._last_refresh = 0.0
self._lock = threading.Lock()
def refresh_market(self, sym: str):
now = time.time()
if now - self._last_refresh < _REFRESH_S:
return
try:
with mt5_lock(timeout=2) as got:
if not got:
return
bars = mt5.copy_rates_from_pos(sym, _TF, 0, _N_BARS)
if bars is None or len(bars) < _REG_N + 5:
return
# letzte (offene) Kerze weglassen → nur abgeschlossene Struktur
highs = [float(b["high"]) for b in bars[:-1]]
lows = [float(b["low"]) for b in bars[:-1]]
closes = [float(b["close"]) for b in bars[:-1]]
times = [int(b["time"]) for b in bars[:-1]] # Broker-Zeit (MQL5-Anker)
n = len(closes)
atr = _atr(highs, lows, closes)
piv = _pivots(highs, lows, _PIVOT_K)
labels = _classify(piv)
snap = {
"trend": _trend_state(labels) if labels else "range",
"swings": [{"type": l["type"], "price": l["price"]}
for l in labels[-_MAX_SWINGS:]],
"last_swing": labels[-1]["type"] if labels else None,
"bos": _last_bos(labels, n),
"channel": _channel(closes, atr),
"channel_line": _channel_anchors(closes, times, atr),
"tf": "M30",
"error": None,
}
with self._lock:
self._snap = snap
self._last_refresh = now
except Exception as e:
with self._lock:
self._snap["error"] = str(e)[:120]
log.warning(f"MarketStructure.refresh: {e}")
def snapshot(self) -> dict:
with self._lock:
return dict(self._snap)
+709
View File
@@ -0,0 +1,709 @@
"""
core/trader.py — TradeManager
==============================
Verwaltet offene Positionen, sendet Market-Orders an MT5,
loggt Trades in die HistoryLogger-DB.
"""
from __future__ import annotations
import threading
import time
import MetaTrader5 as mt5
from core.config import (
DEVIATION, MAGIC,
SL_BUFFER_TICKS, INIT_SL_FALLBACK, INIT_TP_RR,
INIT_SL_MIN_ATR, INIT_SL_MAX_ATR, get_risk_per_trade,
)
from core.mt5_utils import (
mt5_lock, get_tick, get_filling, calc_lots, calc_lots_risk,
pivot_low, pivot_high, atr_value,
)
from core.logger import get_logger
log_trade = get_logger("trade")
log_hist = get_logger("hist")
# Klartext für die häufigsten MT5-Order-Retcodes (statt „retcode=10027")
_RETCODE_MSG = {
10004: "Requote — Preis hat sich bewegt, nochmal",
10006: "Order abgelehnt",
10013: "Ungültige Anfrage",
10014: "Ungültiges Volumen (Lots)",
10015: "Ungültiger Preis",
10016: "Ungültiger SL/TP",
10017: "Handel deaktiviert",
10018: "Markt geschlossen",
10019: "Nicht genug Geld / Margin",
10020: "Preis verändert — nochmal",
10021: "Kein Preis (Markt zu / kein Tick)",
10024: "Zu viele Anfragen — kurz warten",
10026: "Algo-Handel SERVERSEITIG aus (Broker)",
10027: "⚠ Algo-Trading im MT5-Terminal AUS — 'Algo Trading'-Button aktivieren!",
10030: "Ungültiger Füllmodus",
10031: "Keine Verbindung zum Handelsserver",
}
def _retcode_msg(res) -> str:
rc = res.retcode if res else None
return _RETCODE_MSG.get(rc, f"Order-Fehler (retcode={rc})")
class TradeManager:
def __init__(self):
self.ticket = self.order_type = None
self.entry_price = self.lots = self.pnl = self.cur_price = 0.0
self.sl = self.tp = self.margin = 0.0
self.symbol = None
self.last_error = ""
self._lock = threading.Lock()
self.history: 'HistoryLogger | None' = None
self._open_context: dict = {}
self._swap: float = 0.0
self._commission: float = 0.0
self._tick_size: float | None = None
self._tick_value: float | None = None
self._si_cache: object = None
self._si_cache_ts: float = 0.0
def _calc_sl_tp(self, sym, otype, entry_price):
si = mt5.symbol_info(sym)
if not si:
return 0.0, 0.0, "?"
buf = SL_BUFFER_TICKS * (si.trade_tick_size or si.point)
# Risiko-Deckel: SL-Distanz max. INIT_SL_MAX_ATR × ATR(M15).
# Pivot-SLs lagen teils ~80 Pips weg → Einzelverluste -30..-40 €
# bei Durchschnittsgewinnen von ~+4 €.
atr = atr_value(sym)
max_dist = (INIT_SL_MAX_ATR * atr) if atr else None
min_dist = (INIT_SL_MIN_ATR * atr) if atr else None
capped = floored = False
if otype == mt5.ORDER_TYPE_BUY:
piv = pivot_low(sym, entry_price)
sl = (piv - buf) if piv else round(entry_price * (1 - INIT_SL_FALLBACK), si.digits)
if max_dist and entry_price - sl > max_dist:
sl = entry_price - max_dist; capped = True
if min_dist and entry_price - sl < min_dist:
sl = entry_price - min_dist; floored = True
sl = round(sl, si.digits)
sl_dist = entry_price - sl
tp = round(entry_price + INIT_TP_RR * sl_dist, si.digits) if sl_dist > 0 else 0.0
else:
piv = pivot_high(sym, entry_price)
sl = (piv + buf) if piv else round(entry_price * (1 + INIT_SL_FALLBACK), si.digits)
if max_dist and sl - entry_price > max_dist:
sl = entry_price + max_dist; capped = True
if min_dist and sl - entry_price < min_dist:
sl = entry_price + min_dist; floored = True
sl = round(sl, si.digits)
sl_dist = sl - entry_price
tp = round(entry_price - INIT_TP_RR * sl_dist, si.digits) if sl_dist > 0 else 0.0
src_str = f"M15-Pivot {piv:.3f}" if piv else "Fallback 1.2%"
if capped:
src_str += f", gekappt auf {INIT_SL_MAX_ATR}xATR={max_dist:.3f}"
if floored:
src_str += f", auf min {INIT_SL_MIN_ATR}xATR={min_dist:.3f} aufgeweitet"
return sl, tp, src_str
def _send(self, sym, otype):
with mt5_lock(timeout=15) as got:
if not got:
self.last_error = "MT5 belegt — bitte gleich nochmal"
return None, 0.0
return self._send_locked(sym, otype)
def _send_locked(self, sym, otype):
tick = get_tick(sym)
if not tick:
self.last_error = "Kein Tick"; return None, 0.0
price = tick.ask if otype == mt5.ORDER_TYPE_BUY else tick.bid
# SL ZUERST bestimmen → daraus risiko-basierte Lot-Größe (Verlust beim
# Initial-SL ≈ risk_pct der Equity). Margin bleibt Obergrenze. Fallback auf
# margin-basiert, wenn risk_pct=0 oder Daten fehlen. Behebt die großen
# EUR-Verluste aus 90 %-Margin × 2×ATR-SL.
sl, _tp, sl_src = self._calc_sl_tp(sym, otype, price)
risk = get_risk_per_trade()
sl_dist = abs(price - sl) if sl else None
if risk > 0:
# Risiko-Modus: KEIN stiller Fallback auf Margin-Sizing (75 % wäre ein
# Vielfaches des gewollten Risikos). Klappt die Risiko-Rechnung nicht
# (Daten fehlen / unter Mindestlot), wird der Trade abgelehnt.
lots = calc_lots_risk(sym, price, otype, sl_dist, risk)
if lots <= 0:
self.last_error = ("Risiko-Sizing nicht möglich (unter Mindestlot "
"oder Daten fehlen) — Trade abgelehnt")
return None, 0.0
else:
lots = calc_lots(sym, price, otype) # margin-basiert (risk_pct=0)
if lots <= 0:
self.last_error = "Lot-Fehler"; return None, 0.0
req = {
"action": mt5.TRADE_ACTION_DEAL, "symbol": sym, "volume": float(lots),
"type": otype, "price": float(price), "deviation": DEVIATION,
"magic": MAGIC,
"comment": f"Widget-{'BUY' if otype == mt5.ORDER_TYPE_BUY else 'SELL'}",
"type_filling": get_filling(sym),
}
if sl:
req["sl"] = float(sl)
res = mt5.order_send(req)
for mode in (mt5.ORDER_FILLING_RETURN, mt5.ORDER_FILLING_IOC, mt5.ORDER_FILLING_FOK):
if res and res.retcode != 10030:
break
req["type_filling"] = mode
res = mt5.order_send(req)
if res and res.retcode == mt5.TRADE_RETCODE_DONE:
log_trade.info(
f"{'BUY' if otype == mt5.ORDER_TYPE_BUY else 'SELL'} "
f"{lots:.2f}L @ {price:.3f} T={res.order} SL={sl:.3f} ({sl_src})")
# tick_size/value/lots sofort cachen — sonst liefert live_pnl()
# bis zum ersten Positions-Tick (≤1 s) None und die P&L bleibt leer
si = mt5.symbol_info(sym)
with self._lock:
self.lots = float(lots); self._swap = 0.0; self._commission = 0.0
if si:
self._tick_size = si.trade_tick_size or self._tick_size or 0.001
self._tick_value = si.trade_tick_value or self._tick_value or 1.0
return res.order, float(price)
self.last_error = _retcode_msg(res)
return None, 0.0
def open_long(self, sym):
with self._lock:
if self.ticket:
return "Position bereits offen!"
t, e = self._send(sym, mt5.ORDER_TYPE_BUY)
if not t:
return self.last_error
with self._lock:
self.ticket = t; self.order_type = mt5.ORDER_TYPE_BUY
self.entry_price = e; self.symbol = sym
self._log_open(t, sym, "BUY", e)
return ""
def open_short(self, sym):
with self._lock:
if self.ticket:
return "Position bereits offen!"
t, e = self._send(sym, mt5.ORDER_TYPE_SELL)
if not t:
return self.last_error
with self._lock:
self.ticket = t; self.order_type = mt5.ORDER_TYPE_SELL
self.entry_price = e; self.symbol = sym
self._log_open(t, sym, "SELL", e)
return ""
def close(self, reason: str = "manual"):
with mt5_lock(timeout=15) as got:
if not got:
return "MT5 belegt — bitte gleich nochmal"
return self._close_locked(reason)
def _close_locked(self, reason: str = "manual"):
with self._lock:
ticket = self.ticket; sym = self.symbol; otype = self.order_type
if not ticket:
return "Keine offene Position."
positions = mt5.positions_get(ticket=ticket)
if not positions:
with self._lock:
self.ticket = None
return "Position bereits geschlossen."
pos = positions[0]
tick = get_tick(sym)
if not tick:
return "Kein Tick."
ct = mt5.ORDER_TYPE_SELL if otype == mt5.ORDER_TYPE_BUY else mt5.ORDER_TYPE_BUY
cp = tick.bid if otype == mt5.ORDER_TYPE_BUY else tick.ask
req = {
"action": mt5.TRADE_ACTION_DEAL, "symbol": sym, "volume": float(pos.volume),
"type": ct, "position": ticket, "price": float(cp), "deviation": DEVIATION,
"magic": MAGIC, "comment": "Widget-CLOSE",
"type_filling": get_filling(sym),
}
res = mt5.order_send(req)
for mode in (mt5.ORDER_FILLING_RETURN, mt5.ORDER_FILLING_IOC, mt5.ORDER_FILLING_FOK):
if res and res.retcode != 10030:
break
req["type_filling"] = mode
res = mt5.order_send(req)
if res and res.retcode == mt5.TRADE_RETCODE_DONE:
log_trade.info(f"CLOSE T={ticket} @ {cp:.3f} ({reason})")
self._log_close(ticket, cp, pos.profit, reason)
with self._lock:
self.ticket = None; self.order_type = None
self.entry_price = 0.0; self.lots = 0.0
self.pnl = 0.0; self.cur_price = 0.0; self.sl = self.tp = self.margin = 0.0
self._swap = 0.0; self._tick_size = None; self._tick_value = None
return ""
return "Close: " + _retcode_msg(res)
def partial_close_position(self, pos, si, frac: float = 0.5,
reason: str = "partial"):
"""
Schließt `frac` des Volumens einer offenen Position (Teil-Exit / Runner).
CALLER MUSS den globalen mt5_lock bereits halten (wird vom Trailing
innerhalb von _do_modify aufgerufen).
Rückgabe: (geschlossenes_volumen, schlusskurs) bei Erfolg,
sonst (0.0, fehlertext).
Die realisierte Teil-PnL wird NICHT separat geloggt — sie steckt als
eigener Deal an derselben position_id und wird beim finalen Close über
_log_external_close in die Gesamt-PnL des Trades aufsummiert.
"""
sym = getattr(pos, "symbol", self.symbol)
step = si.volume_step or 0.01
vmin = si.volume_min or step
full = float(pos.volume)
vol_close = round(round((full * frac) / step) * step, 8)
# Beide Seiten müssen >= Mindestvolumen bleiben — sonst kein Teil-Exit
if vol_close < vmin or (full - vol_close) < vmin:
return 0.0, "Volumen zu klein zum Teilen"
tick = get_tick(sym)
if not tick:
return 0.0, "Kein Tick"
otype = pos.type
ct = mt5.ORDER_TYPE_SELL if otype == mt5.ORDER_TYPE_BUY else mt5.ORDER_TYPE_BUY
cp = tick.bid if otype == mt5.ORDER_TYPE_BUY else tick.ask
req = {
"action": mt5.TRADE_ACTION_DEAL, "symbol": sym,
"volume": float(vol_close), "type": ct, "position": pos.ticket,
"price": float(cp), "deviation": DEVIATION, "magic": MAGIC,
"comment": "Widget-PARTIAL", "type_filling": get_filling(sym),
}
res = mt5.order_send(req)
for mode in (mt5.ORDER_FILLING_RETURN, mt5.ORDER_FILLING_IOC,
mt5.ORDER_FILLING_FOK):
if res and res.retcode != 10030:
break
req["type_filling"] = mode
res = mt5.order_send(req)
if res and res.retcode == mt5.TRADE_RETCODE_DONE:
log_trade.info(
f"TEIL-EXIT ({reason}): {vol_close:.2f}L von {full:.2f}L "
f"@ {cp:.3f} T={pos.ticket}")
with self._lock:
self.lots = max(full - vol_close, 0.0)
return vol_close, float(cp)
return 0.0, f"retcode={res.retcode if res else 'None'}"
def set_open_context(self, *, ai_sentiment=None, ai_confidence=None,
rec_signal=None, rec_score=None,
setup=None, regime=None, rsi=None, news_score=None):
self._open_context = {
"ai_sentiment": ai_sentiment, "ai_confidence": ai_confidence,
"rec_signal": rec_signal, "rec_score": rec_score,
"setup": setup, "regime": regime, "rsi": rsi, "news_score": news_score,
}
def _log_open(self, ticket: int, sym: str, direction: str, entry_price: float):
if not self.history:
return
ctx = dict(self._open_context)
def delayed_log():
time.sleep(0.5)
sl = tp = None
try:
# Eigener Thread → MT5-Call MUSS über den globalen Lock laufen
# (sonst Race gegen copy_rates/positions_get der anderen Loops).
with mt5_lock(timeout=5) as got:
positions = mt5.positions_get(ticket=ticket) if got else None
if positions:
sl = float(positions[0].sl) or None
tp = float(positions[0].tp) or None
except Exception as e:
log_hist.warning(f"SL/TP-Lookup: {e}")
try:
self.history.log_trade_open(
ticket=ticket, symbol=sym, direction=direction,
lots=float(self.lots) or 0.0, entry_price=entry_price,
sl_at_entry=sl, tp_at_entry=tp,
ai_sentiment=ctx.get("ai_sentiment"),
ai_confidence=ctx.get("ai_confidence"),
rec_signal=ctx.get("rec_signal"),
rec_score=ctx.get("rec_score"),
setup=ctx.get("setup"), regime=ctx.get("regime"),
rsi_at_entry=ctx.get("rsi"), news_score=ctx.get("news_score"),
)
except Exception as e:
log_hist.error(f"log_trade_open: {e}")
threading.Thread(target=delayed_log, daemon=True).start()
def _log_close(self, ticket: int, exit_price: float, pnl: float, closed_by: str):
if not self.history:
return
try:
self.history.log_trade_close(
ticket=ticket, exit_price=exit_price, pnl=pnl, closed_by=closed_by,
)
except Exception as e:
log_hist.error(f"log_trade_close: {e}")
def refresh(self, sym):
with mt5_lock() as got:
if not got:
return
self._refresh_locked(sym)
def _refresh_locked(self, sym):
with self._lock:
ticket = self.ticket
if ticket is None:
on_sym = mt5.positions_get(symbol=sym) or []
all_pos = on_sym if on_sym else (mt5.positions_get() or [])
if all_pos:
own = [p for p in all_pos if getattr(p, "magic", 0) == MAGIC]
pick = own[0] if own else all_pos[0]
pos_sym = getattr(pick, "symbol", sym)
with self._lock:
self.ticket = pick.ticket; self.order_type = pick.type
self.entry_price = pick.price_open; self.symbol = pos_sym
self.lots = pick.volume; self.pnl = pick.profit
self.sl = float(getattr(pick, "sl", 0.0) or 0.0)
self.tp = float(getattr(pick, "tp", 0.0) or 0.0)
# Adoptierter Trade ohne SL → Schutz-SL nachrüsten (Lock gehalten)
if not self.sl:
psl, _ptp, _ps = self._calc_sl_tp(pos_sym, pick.type,
float(pick.price_open))
if psl:
r = mt5.order_send({"action": mt5.TRADE_ACTION_SLTP,
"symbol": pos_sym,
"position": pick.ticket,
"sl": float(psl)})
if r and r.retcode == mt5.TRADE_RETCODE_DONE:
with self._lock:
self.sl = float(psl)
log_trade.info(
f"Schutz-SL für adoptierten Trade "
f"T={pick.ticket} @ {psl:.3f}")
else:
log_trade.warning(
f"Schutz-SL fehlgeschlagen T={pick.ticket} "
f"rc={r.retcode if r else 'None'}")
try: # gebundene Margin (eingesetzter Betrag)
_m = mt5.order_calc_margin(pick.type, pos_sym,
pick.volume, pick.price_open)
if _m:
with self._lock:
self.margin = float(_m)
except Exception:
pass
source = "magic-match" if own else "externer Trade adoptiert"
cross = " ⚠ ANDERES Symbol!" if pos_sym != sym else ""
log_trade.info(
f"Position erkannt: T={pick.ticket} {pos_sym} "
f"{'BUY' if pick.type == mt5.ORDER_TYPE_BUY else 'SELL'} "
f"{pick.volume}L @ {pick.price_open:.3f} ({source}){cross}")
if self.history:
direction = "BUY" if pick.type == mt5.ORDER_TYPE_BUY else "SELL"
self.history.log_trade_open(
ticket=int(pick.ticket),
symbol=pos_sym,
direction=direction,
lots=float(pick.volume),
entry_price=float(pick.price_open),
)
return
pos = mt5.positions_get(ticket=ticket)
if not pos:
with self._lock:
last_pnl = self.pnl
last_price = self.cur_price
last_commission = self._commission
log_trade.info(f"Position {ticket} extern geschlossen pnl≈{last_pnl:.2f} commission={last_commission:.2f}")
# MT5 braucht ~1-2s um den Close-Deal in die History zu schreiben.
# Async mit kurzem Delay aufrufen, damit history_deals_get den Deal findet
# und closed_by korrekt als "manual"/"sl"/"tp" gesetzt wird (nicht "unknown").
def _log_async(t=ticket, pnl=last_pnl, price=last_price, comm=last_commission):
time.sleep(2)
with mt5_lock(timeout=10) as _got:
if _got:
self._log_external_close(t, fallback_pnl=pnl,
fallback_price=price,
fallback_commission=comm)
threading.Thread(target=_log_async, daemon=True).start()
with self._lock:
self.ticket = None; self.order_type = None
self.entry_price = 0.0; self.pnl = 0.0; self.lots = 0.0
self.cur_price = 0.0; self.sl = self.tp = self.margin = 0.0
self._swap = 0.0; self._commission = 0.0
self._tick_size = None; self._tick_value = None
return
p = pos[0]
tick = get_tick(sym)
swap = float(getattr(p, "swap", 0.0) or 0.0)
commission = float(getattr(p, "commission", 0.0) or 0.0)
now = time.time()
if now - self._si_cache_ts > 5.0:
self._si_cache = mt5.symbol_info(sym)
self._si_cache_ts = now
si = self._si_cache
try: # gebundene Margin (eingesetzter Betrag)
_m = mt5.order_calc_margin(p.type, sym, p.volume, p.price_open)
margin = float(_m) if _m else 0.0
except Exception:
margin = 0.0
with self._lock:
self.lots = p.volume
self._swap = swap
self._commission = commission
self.pnl = p.profit + swap
self.sl = float(getattr(p, "sl", 0.0) or 0.0)
self.tp = float(getattr(p, "tp", 0.0) or 0.0)
self.margin = margin
self.cur_price = (tick.bid if p.type == mt5.ORDER_TYPE_BUY
else tick.ask) if tick else p.price_current
if si:
self._tick_size = si.trade_tick_size or self._tick_size or 0.001
self._tick_value = si.trade_tick_value or self._tick_value or 1.0
def live_pnl(self, bid: float, ask: float) -> float | None:
with self._lock:
if self.ticket is None:
return None
otype = self.order_type; ep = self.entry_price
lots = self.lots; ts = self._tick_size
tv = self._tick_value; swap = self._swap
if not ts or not tv:
return None
cur = bid if otype == mt5.ORDER_TYPE_BUY else ask
diff = (cur - ep) if otype == mt5.ORDER_TYPE_BUY else (ep - cur)
return diff / ts * tv * lots + swap
def _broker_offset_s(self) -> int:
"""
Broker-Serverzeit minus UTC in Sekunden, auf 30 min gerundet
(z.B. UTC+3 → 10800). MT5 liefert deal.time/tick.time in
Broker-Zeit, NICHT in UTC — ohne Korrektur landen Timestamps
3 h verschoben in der DB.
Außerhalb der Handelszeiten kann der letzte Tick alt sein →
Ergebnis wird auf plausiblen Bereich [-12h, +14h] geprüft,
sonst 0 (keine Korrektur).
"""
try:
sym = self.symbol
tick = mt5.symbol_info_tick(sym) if sym else None
if tick and tick.time:
off = round((tick.time - time.time()) / 1800) * 1800
if -12 * 3600 <= off <= 14 * 3600:
return int(off)
except Exception:
pass
return 0
def _log_external_close(self, ticket: int,
fallback_pnl: float | None = None,
fallback_price: float | None = None,
fallback_commission: float = 0.0,
lookback_hours: int = 24):
"""
Versucht den externen Close über MT5-Deal-History zu rekonstruieren.
Methode 1 (primär): history_deals_get(position=ticket) ohne Zeitrange.
Ruft intern HistoryDealsGetByPosition() auf — sucht in der
kompletten History und funktioniert auf den meisten Brokern.
Methode 2 (Fallback): Zeitfenster-Suche nach position_id == ticket.
Greift, wenn Methode 1 leer zurückkommt (seltener Broker-Bug).
Methode 3 (letzter Ausweg): letzter bekannter PnL aus Trader-State,
closed_by bleibt "unknown".
"""
if not self.history:
return
try:
# ── Methode 1: position-basierter Lookup (kein Zeitfenster) ──────
pos_deals = mt5.history_deals_get(position=ticket)
own = [d for d in (pos_deals or [])
if getattr(d, "position_id", None) == ticket]
# ── Methode 2: Zeitfenster + position_id-Filter ───────────────────
if not own:
# history_deals_get filtert nach BROKER-Zeit, nicht UTC —
# ohne Offset läge das Fensterende 3h vor Broker-jetzt und
# frisch geschlossene Deals fielen heraus.
now_b = int(time.time()) + self._broker_offset_s()
from_ts = now_b - lookback_hours * 3600
range_deals = mt5.history_deals_get(from_ts, now_b + 300)
own = [d for d in (range_deals or [])
if getattr(d, "position_id", None) == ticket]
if own:
log_hist.debug(f"T={ticket}: Methode-2 lieferte {len(own)} Deals")
else:
n1 = len(pos_deals) if pos_deals else 0
n2 = len(range_deals) if range_deals else 0
log_hist.debug(
f"T={ticket}: keine Deals mit position_id={ticket} "
f"(M1={n1} Deals, M2={n2} Deals — Broker setzt position_id nicht)")
if own:
deals_sorted = sorted(own, key=lambda d: getattr(d, "time", 0))
close_deal = next(
(d for d in reversed(deals_sorted)
if d.entry == mt5.DEAL_ENTRY_OUT), None)
if close_deal:
reason = getattr(close_deal, "reason", None)
closed_by = "unknown"
try:
if reason == mt5.DEAL_REASON_SL: closed_by = "sl"
elif reason == mt5.DEAL_REASON_TP: closed_by = "tp"
elif reason in (mt5.DEAL_REASON_CLIENT,
mt5.DEAL_REASON_EXPERT,
mt5.DEAL_REASON_MOBILE,
mt5.DEAL_REASON_WEB): closed_by = "manual"
except AttributeError:
pass
commission = sum(getattr(d, "commission", 0) for d in own)
total_profit = sum(
getattr(d, "profit", 0) + getattr(d, "swap", 0)
+ getattr(d, "commission", 0)
for d in own)
# deal.time ist Broker-Zeit (z.B. UTC+3) → in UTC umrechnen
raw_ts = int(getattr(close_deal, "time", 0) or 0)
exit_ts = (raw_ts - self._broker_offset_s()) if raw_ts \
else int(time.time())
self.history.log_trade_close(
ticket=ticket, exit_price=float(close_deal.price),
pnl=float(total_profit), closed_by=closed_by,
exit_ts=exit_ts, commission=float(commission))
log_hist.info(
f"Externer Close: T={ticket} {closed_by} @ "
f"{close_deal.price:.3f} pnl={total_profit:.2f}")
return
log_hist.warning(f"T={ticket}: kein OUT-Deal in {len(own)} Deals")
# ── Methode 3: Fallback — letzter bekannter PnL ───────────────────
if fallback_pnl is not None:
self.history.log_trade_close(
ticket=ticket,
exit_price=float(fallback_price or 0.0),
pnl=float(fallback_pnl),
closed_by="unknown",
exit_ts=int(time.time()),
commission=fallback_commission)
log_hist.warning(
f"Externer Close (Fallback-PnL): T={ticket} "
f"pnl≈{fallback_pnl:.2f} commission={fallback_commission:.2f} "
f"price≈{fallback_price or 0:.3f}")
else:
log_hist.warning(
f"T={ticket}: keine Deal-Daten, kein Fallback-PnL — "
f"wird bei Reconcile als 'unknown' eingetragen")
except Exception as e:
log_hist.error(f"_log_external_close: {e}")
def reconcile_open_trades(self, lookback_hours: int = 168):
if not self.history:
return
open_trades = self.history.open_trades()
if not open_trades:
log_hist.info("Reconcile: keine offenen Trades in DB")
return
log_hist.info(f"Reconcile: prüfe {len(open_trades)} offene DB-Einträge …")
n_closed = n_orphaned = 0
cutoff_ts = int(time.time()) - lookback_hours * 3600
for trade in open_trades:
ticket = trade["ticket"]
try:
if mt5.positions_get(ticket=ticket):
continue
except Exception:
pass
self._log_external_close(ticket, lookback_hours=lookback_hours)
try:
still_open_ids = {t["ticket"] for t in self.history.open_trades()}
if ticket not in still_open_ids:
n_closed += 1
else:
# Position in MT5 weg, aber kein Deal gefunden →
# sofort als 'unknown' markieren (kein Age-Cutoff nötig,
# da MT5-Abwesenheit bereits bestätigt wurde).
self.history.log_trade_close(
ticket=ticket, exit_price=0.0,
pnl=0.0, closed_by="unknown",
exit_ts=int(time.time()))
n_orphaned += 1
log_hist.warning(
f"Reconcile: T={ticket} nicht in MT5 + keine Deals "
f"→ als 'unknown' markiert")
except Exception as e:
log_hist.error(f"Reconcile-Check T={ticket}: {e}")
log_hist.info(f"Reconcile fertig: {n_closed} nachgetragen, "
f"{n_orphaned} als 'unknown' markiert")
def modify_sltp(self, sl=None, tp=None):
"""Manuelles Setzen von SL/TP der offenen Position (TRADE_ACTION_SLTP).
None/leer = jeweiligen Broker-Wert beibehalten; 0 = entfernen. Prüft
Seite/Mindestabstand vorab (freundlichere Meldung als der Broker-Retcode).
Gibt Fehlertext zurück oder None bei Erfolg."""
with mt5_lock(timeout=5) as got:
if not got:
return "MT5 belegt"
if not self.ticket:
return "keine Position"
positions = mt5.positions_get(ticket=self.ticket)
if not positions:
return "keine Position"
pos = positions[0]; sym = pos.symbol
si = mt5.symbol_info(sym); tick = mt5.symbol_info_tick(sym)
if not si or not tick:
return "kein Symbol/Tick"
is_long = pos.type == mt5.ORDER_TYPE_BUY
cur = tick.bid if is_long else tick.ask
spread = getattr(si, "spread", 0) or 0
min_dist = max((si.trade_stops_level + spread + 5) * si.point, 0.01)
def _val(x, keep):
if x in (None, ""):
return float(keep or 0.0)
return float(x)
new_sl = _val(sl, pos.sl); new_tp = _val(tp, pos.tp)
if new_sl:
if is_long and new_sl >= cur - min_dist:
return f"SL muss < {cur - min_dist:.3f} liegen (unter Kurs)"
if not is_long and new_sl <= cur + min_dist:
return f"SL muss > {cur + min_dist:.3f} liegen (über Kurs)"
if new_tp:
if is_long and new_tp <= cur + min_dist:
return f"TP muss > {cur + min_dist:.3f} liegen (über Kurs)"
if not is_long and new_tp >= cur - min_dist:
return f"TP muss < {cur - min_dist:.3f} liegen (unter Kurs)"
res = mt5.order_send({
"action": mt5.TRADE_ACTION_SLTP,
"symbol": sym,
"position": pos.ticket,
"sl": round(new_sl, si.digits),
"tp": round(new_tp, si.digits),
})
if not res or res.retcode != mt5.TRADE_RETCODE_DONE:
return f"Broker lehnte ab (rc={getattr(res, 'retcode', '?')}: " \
f"{getattr(res, 'comment', '?')})"
with self._lock:
self.sl = round(new_sl, si.digits)
self.tp = round(new_tp, si.digits)
log.info(f"Manuelles SLTP: SL={self.sl} · TP={self.tp} (Ticket {pos.ticket})")
return None
def snapshot(self):
with self._lock:
return dict(
ticket=self.ticket, order_type=self.order_type,
entry_price=self.entry_price, lots=self.lots,
pnl=self.pnl, cur_price=self.cur_price,
sl=self.sl, tp=self.tp, margin=self.margin,
)
+717
View File
@@ -0,0 +1,717 @@
"""
core/trailing.py — TrailingManager (tick-basiert)
===================================================
High-Water-Mark wird bei jedem Preis-Tick aktualisiert (kein MT5-Call).
ATR aus Kerzenschlüssen — Timeframe wird automatisch gewählt:
starker Trend (Winkel-Abstand > 45°) → H1 (große Moves, breiter Puffer)
moderater Trend (20°–45°) → M30 (Mittelweg)
Seitwärts (<20°) → M15 (normale Range)
Gecacht, refresht nur bei neuer Kerze des jeweiligen Timeframes.
SL/TP-Modifikation via MT5 nur wenn Cooldown abgelaufen + Schwellwert überschritten.
SL drei Phasen (Phasen-Ratsche: nur vorwärts Init→Trail→Lock, nie zurück):
1. Profit < 0.3 × ATR → Initial-SL setzen (falls noch keiner), dann halten
2. 0.3 ≤ Profit < 3.5 → SL = HW ∓ mult×ATR; Breakeven-Floor (Entry) erst
ab Profit ≥ 1.0×ATR — sonst stoppt jeder Rückläufer
zum Entry mit ±0 aus
3. Profit ≥ 3.5 × ATR → Engeres Trailing (mult × 0.6, min 1.2×) zum Gewinn-Lock-In
TP (Forward-Ziel — zieht mit High-Water nach vorne mit):
Phase 1 (Init): TP = Entry ± 3.5×ATR (fester Forward-Puffer, kein Trailing)
Phase 2 (Trail): TP = HW ∓ 1.5×ATR (Trailing hinter HW — breit genug für Retrace)
Phase 3 (Lock): TP = HW ∓ 0.8×ATR (engeres Lock-in bei tiefem Profit)
TP wird in Init/Trail nie zurückbewegt (nur nach oben LONG bzw. unten SHORT);
in Phase Lock darf er näher an den Kurs rücken (Gewinnsicherung).
Dynamischer Multiplikator (Trendwinkel):
starker Trend (>45° Abstand von 90°) → 2.5× (moderat — Trend braucht Raum)
moderater Trend (20°–45° Abstand) → 2.0× (enger)
Seitwärts (<20° Abstand) → 3.0× (weiter, weil kein klarer Trend)
Stabilität: ATR-Timeframe UND Multiplikator werden beim Aktivieren eingefroren
und gelten für den gesamten Trade. Vorher wechselte die TF-Automatik mitten im
Trade (M30→H1: ATR 0.41→1.08) → Phase fiel von Trail auf Init zurück und der
SL wurde nie wieder bewegt. Manueller TF-Override im UI greift weiterhin sofort.
ATR-Floor: max(gemessener ATR, 0.25) — verhindert zu enges Trailing in ruhigen Märkten.
Schwellwert: max(8 Punkte, 4 % des ATR) — skaliert mit Marktvolatilität.
"""
from __future__ import annotations
import threading
import time
import MetaTrader5 as mt5
from core.mt5_utils import mt5_lock
from core.logger import get_logger
log = get_logger("trail")
_ATR_PERIOD = 14
_ATR_MIN = 0.06 # Untergrenze ATR. 0,12→0,06 gesenkt (gemessen,
# `backtest_atrfloor.py`, 2 Halbjahre): reale Vola fiel
# unter den alten Floor (H1: 81 % der Signale < 0,12 →
# Floor band dauernd, Exit-Distanzen künstlich ~1,31,6×
# breit). 0,06 in BEIDEN Hälften besser (H1 153→−27 Pts,
# H2 +512→+554); ganz ohne Floor nur marginal besser →
# 0,06 als Schutz gegen Absurd-ATR (Dead-Hours) behalten.
_TRAIL_START_ATR = 0.3 # Phase 1→2: ab diesem Profit startet HW-Trailing
_BREAKEVEN_ATR = 1.3 # Entry-Floor (Breakeven) ab diesem Profit. 0,6 war zu
# eng: 21 % der Trades wurden auf Breakeven gescratcht
# (backtest_exit.py be). 1,3 = gemessenes Plateau-Optimum:
# Scratch 5 %, Ø-R +15 %, ohne Tail-Risiko (SL-gedeckelt).
_PHASE4_ATR = 3.5 # Phase 2→3: ab hier engeres Trailing zum Lock-In
_PHASE4_MULT_SCALE = 0.6 # Multiplikator-Faktor in Phase 3
_PHASE4_MULT_MIN = 1.2 # Untergrenze Mult in Phase 3
_PARTIAL_TP_ATR = 1.5 # Teil-Exit: ab diesem Profit 1× die Hälfte sichern
_PARTIAL_TP_FRAC = 0.0 # Anteil beim Teil-Exit. 0 = AUS → kompletter Trade
# läuft bis TP/SL (User-Vorgabe: am TP voll schließen)
_TIMESTOP_MIN = 120 # Time-Stop (gemessen, `backtest_timestop.py`): Trade
# hängt nach 2 h noch in Phase "Init" (HW-Profit nie
# ≥0,3×ATR = Whipsaw-Opfer) → schließen. Verbessert
# BEIDE History-Hälften (H1 +98→+162, H2 +2191→+2298 R),
# Worst unverändert; kurze N (30/60 min) = Rauschen.
# 0 = aus.
_ADVERSE_EXIT_ATR = 0.0 # Früh-Ausstieg: läuft der Trade ≥ diese ATR-Distanz
# GEGEN den Einstieg, sofort schließen. 0 = AUS.
# Abgeschaltet: kappte trend-konforme Trades schon auf
# normalen 1×ATR-Bounces, bevor der Broker-SL (1.21.5×
# ATR) Raum gab. Schutz läuft jetzt allein über den SL.
_TP_TRAIL_ATR = 1.5 # Phase 2 (Trail): TP = HW ∓ 1.5×ATR (mehr Raum für Retrace)
_TP_LOCK_ATR = 0.8 # Phase 3 (Lock): TP = HW ∓ 0.8×ATR (Lock-in, weniger eng)
_TP_INIT_ATR = 3.5 # Phase 1 (Init): TP = Entry ± 3.5×ATR (weiter Forward-Puffer)
_MODIFY_COOLDOWN_S = 8 # normaler Abstand zwischen zwei Modifikationen
_FAST_COOLDOWN_S = 1 # Cooldown bei Ausbruch (HW bewegt sich schnell)
_BREAKOUT_ATR_FACTOR = 0.5 # HW-Bewegung > 0.5×ATR seit letztem Modify → Ausbruch erkannt
_ERROR_COOLDOWN_S = 30 # Pause nach fehlgeschlagenem order_send
_ACTIVATE_DELAY_S = 2 # Wartezeit nach Aktivierung bis erste Modifikation
_WARMUP_TICKS = 3 # erste N Ticks: kein Modify
_SL_THRESHOLD_PTS = 8 # SL-Mindestverbesserung in Punkten
_SL_THRESHOLD_ATR = 0.04 # SL-Mindestverbesserung als ATR-Anteil (max der beiden)
# Fallback-ATR-TF-Wahl nach Trendstärke (nur wenn kein Override gesetzt ist —
# normalerweise koppelt das Widget den TF an die Wellen-/Agent-TF).
_TF_STRONG = mt5.TIMEFRAME_H1 # starker Trend → H1
_TF_MODERATE = mt5.TIMEFRAME_M30 # moderater Trend → M30
_TF_RANGE = mt5.TIMEFRAME_M15 # Seitwärts → M15
_TF_LABELS = {
mt5.TIMEFRAME_M1: "M1",
mt5.TIMEFRAME_M5: "M5",
mt5.TIMEFRAME_M15: "M15",
mt5.TIMEFRAME_M30: "M30",
mt5.TIMEFRAME_H1: "H1",
}
# Trail-Multiplikator je Timeframe: niedrige TF (Scalp) → enger Stop,
# hohe TF (Trend) → mehr Puffer. Ersetzt die alte 2.54.5-Logik.
_MULT_BY_TF = {
mt5.TIMEFRAME_M1: 1.5,
mt5.TIMEFRAME_M5: 1.5,
mt5.TIMEFRAME_M15: 2.0,
mt5.TIMEFRAME_M30: 2.5,
mt5.TIMEFRAME_H1: 3.0,
}
_MULT_BREAKOUT_ADD = 0.5 # Breakouts brauchen etwas mehr Luft (gedeckelt)
_PHASE_RANK = {"Init": 0, "Trail": 1, "Lock": 2}
class TrailingManager:
"""Tick-basiertes Trailing-Stop-System mit M15-ATR als Abstandsmaß."""
def __init__(self, trader):
self.trader = trader
self.enabled = False
self._atr: float | None = None # aktiver ATR (vom gewählten TF)
self._atr_tf: int = _TF_RANGE # aktuell genutzter Timeframe
self._atr_tf_override: int | None = None # None = Auto
self._atr_cache: dict = {} # {tf: atr_value}
self._atr_bar_times: dict = {} # {tf: last_bar_time}
self._high_water: float | None = None
self._last_sl: float | None = None
self._last_tp: float | None = None
self._last_modify_ts: float = 0.0
self._last_idle_atr_ts: float = 0.0 # ATR-Refresh wenn Trailing aus
self._ticks: int = 0
self._sr_data: dict | None = None
self._trend_angle: float | None = None
self._phase: str = ""
self._hw_at_last_modify: float | None = None # HW-Stand beim letzten Modify
self._setup: str | None = None # aktives Setup (für Mult-Wahl)
self._active_tf: int | None = None # beim Aktivieren eingefrorener ATR-TF
self._trade_mult: float | None = None # beim Aktivieren eingefrorener Multiplikator
self._partial_done: bool = False # Teil-Exit pro Trade nur einmal
self._notify = None # optionaler Callback (Telegram)
self._no_close_until: float = 0.0 # Startup-Schonfrist (von der Engine gesetzt):
# bis dahin kein Time-Stop-Close nach Neustart
self._lock = threading.Lock()
def set_notify(self, fn):
"""Callback fn(event, volume, price, profit) für Teil-Exit-Meldungen."""
self._notify = fn
def set_no_close_until(self, ts: float):
"""Startup-Schonfrist: bis zu diesem Zeitstempel wird KEIN Time-Stop-Close
ausgelöst (die Engine setzt das beim Start — verhindert, dass ein nach
Neustart adoptierter Alt-Trade sofort per Time-Stop geschlossen wird)."""
self._no_close_until = ts or 0.0
# ── Setter von außen ──────────────────────────────────────────────────────
def set_atr_tf_override(self, tf: int | None):
"""Setzt den ATR-Timeframe (vom Widget an die Wellen-/Agent-TF gekoppelt).
None = Fallback auf Trendstärke-Wahl. Ein laufender Trade behält seinen
beim Aktivieren eingefrorenen TF — die neue Wahl greift erst beim nächsten
Trade (verhindert ATR-Sprünge/Phasen-Rückfall mitten im Trade)."""
with self._lock:
self._atr_tf_override = tf
label = _TF_LABELS.get(tf, "?") if tf is not None else "Auto"
log.info(f"ATR-TF gesetzt: {label}")
def set_sr(self, sr_data: dict | None):
with self._lock:
self._sr_data = sr_data
def set_trend_angle(self, angle: float):
with self._lock:
self._trend_angle = angle
def set_setup(self, setup: str | None):
"""Aktuelles Setup setzen — beeinflusst SL-Multiplikator."""
with self._lock:
self._setup = setup
# ── ATR-Multiplikator (Timeframe-basiert) ─────────────────────────────────
def _trail_mult(self) -> float:
"""Muss unter self._lock aufgerufen werden. Stop-Distanz richtet sich
nach dem Trade-Timeframe: Scalp-TF (M1/M5) → eng, Trend-TF (H1) → weit.
So passt der Stop zum tatsächlichen Trade-Horizont (vorher pauschal
2.54.5, viel zu weit für M1/M5-Scalps)."""
tf = self._active_tf or self._atr_tf
base = _MULT_BY_TF.get(tf, 2.0)
if self._setup and "BREAKOUT" in self._setup:
base += _MULT_BREAKOUT_ADD
return base
# ── Automatische ATR-Timeframe-Wahl ──────────────────────────────────────
def _choose_atr_tf(self) -> int:
"""Wählt ATR-Timeframe: Override wenn gesetzt, sonst automatisch nach Trendstärke."""
with self._lock:
override = self._atr_tf_override
angle = self._trend_angle
if override is not None:
return override
if angle is None:
return _TF_RANGE
strength = abs(angle - 90.0)
if strength > 45:
return _TF_STRONG # starker Trend → H1
elif strength > 20:
return _TF_MODERATE # moderater Trend → M30
return _TF_RANGE # Seitwärts → M15
# ── ATR (gecacht, refresht nur bei neuer Kerze des gewählten TF) ─────────
def _refresh_atr(self, sym: str, tf: int) -> bool:
"""Muss unter aktivem MT5-Lock aufgerufen werden."""
bars = mt5.copy_rates_from_pos(sym, tf, 0, _ATR_PERIOD + 2)
if bars is None or len(bars) < _ATR_PERIOD + 1:
# Fallback: gecachten Wert für diesen TF weiterverwenden
cached = self._atr_cache.get(tf)
if cached is not None:
self._atr = cached
return True
return self._atr is not None
last_bar_time = int(bars[-2]["time"]) # -2 = letzte abgeschlossene Kerze
if last_bar_time == self._atr_bar_times.get(tf) and self._atr_cache.get(tf) is not None:
self._atr = self._atr_cache[tf]
return True
trs = []
for i in range(1, len(bars) - 1): # aktuelle (offene) Kerze weglassen
h = float(bars[i]["high"])
l = float(bars[i]["low"])
c = float(bars[i - 1]["close"])
trs.append(max(h - l, abs(h - c), abs(l - c)))
if trs:
atr_val = sum(trs[-_ATR_PERIOD:]) / min(len(trs), _ATR_PERIOD)
self._atr_cache[tf] = atr_val
self._atr_bar_times[tf] = last_bar_time
prev_tf = self._atr_tf
self._atr = atr_val
self._atr_tf = tf
effective = max(atr_val, _ATR_MIN)
tf_label = _TF_LABELS.get(tf, str(tf))
floor_note = f" (Floor, raw={atr_val:.4f})" if atr_val < _ATR_MIN else ""
tf_note = (f" [TF: {_TF_LABELS.get(prev_tf, '?')}{tf_label}]"
if prev_tf != tf else "")
log.info(f"ATR(14/{tf_label}) = {effective:.4f}{floor_note}{tf_note}")
return self._atr is not None
# ── Haupt-Tick (aufgerufen nach jedem Preis-Tick) ─────────────────────────
def on_price(self, sym: str, bid: float, ask: float):
with self._lock:
if not self.enabled:
# ATR-Anzeige: einmal pro Minute im Idle aktualisieren
now = time.time()
if now - self._last_idle_atr_ts > 60:
self._last_idle_atr_ts = now
else:
return
else:
self._ticks += 1
ticks = self._ticks
if not self.enabled:
tf = self._choose_atr_tf()
with mt5_lock(timeout=1) as got:
if got:
self._refresh_atr(sym, tf)
return
ticks = self._ticks
ps = self.trader.snapshot()
if ps["ticket"] is None:
with self._lock:
if self.enabled:
self.enabled = False
self._high_water = None
self._phase = ""
self._hw_at_last_modify = None
self._setup = None
self._active_tf = None
self._trade_mult = None
self._partial_done = False
log.info("Auto-Deaktiviert (Position geschlossen)")
return
is_long = (ps["order_type"] == mt5.ORDER_TYPE_BUY)
cur = bid if is_long else ask
# High-Water-Mark: immer aktualisieren, ohne MT5-Call
with self._lock:
if self._high_water is None:
self._high_water = cur
elif is_long and cur > self._high_water:
self._high_water = cur
elif not is_long and cur < self._high_water:
self._high_water = cur
hw = self._high_water
last_mod = self._last_modify_ts
# ── Früh-Ausstieg: Trade läuft ≥ _ADVERSE_EXIT_ATR×ATR gegen den Einstieg ──
# Greift auch in der Init-Phase / Warmup, damit „läuft sofort schief"-Trades
# nicht bis zum weiten Initial-Stop ausbluten.
entry = ps.get("entry_price") or 0.0
with self._lock:
atr_ae = max(self._atr, _ATR_MIN) if self._atr is not None else None
if entry and atr_ae and _ADVERSE_EXIT_ATR > 0:
adverse = (entry - cur) if is_long else (cur - entry) # >0 = gegen Position
if adverse >= _ADVERSE_EXIT_ATR * atr_ae:
lots = ps.get("lots", 0.0)
log.warning(
f"🛑 Früh-Ausstieg: {adverse:.3f}{_ADVERSE_EXIT_ATR}×ATR"
f"({atr_ae:.3f}) gegen Einstieg {entry:.3f} → Position schließen")
err = self.trader.close(reason="adverse")
if err:
log.warning(f"Früh-Ausstieg-Close fehlgeschlagen: {err}")
else:
with self._lock:
self.enabled = False
self._high_water = None
self._phase = ""
self._hw_at_last_modify = None
self._setup = None
self._active_tf = None
self._trade_mult = None
self._partial_done = False
if self._notify:
try: self._notify("adverse", lots, cur, -adverse)
except Exception as e: log.warning(f"Adverse-Notify: {e}")
return
if ticks <= _WARMUP_TICKS:
return
# Ausbruch-Erkennung: HW hat sich seit letztem Modify stark bewegt
# → adaptiver Cooldown statt fester 8-Sekunden-Pause
with self._lock:
hw_ref = self._hw_at_last_modify
atr_fb = max(self._atr, _ATR_MIN) if self._atr is not None else None
if hw_ref is not None and atr_fb is not None:
hw_delta = abs(hw - hw_ref)
breakout = hw_delta > _BREAKOUT_ATR_FACTOR * atr_fb
else:
breakout = False
effective_cooldown = _FAST_COOLDOWN_S if breakout else _MODIFY_COOLDOWN_S
if (time.time() - last_mod) < effective_cooldown:
return
with mt5_lock(timeout=2) as got:
if not got:
log.warning("MT5-Lock belegt — Trail-Tick übersprungen")
return
self._do_modify(sym, bid, ask, is_long, hw, ps, breakout=breakout)
# ── SL-Berechnung + MT5 order_send ───────────────────────────────────────
def _do_modify(self, sym: str, bid: float, ask: float,
is_long: bool, hw: float, ps: dict, breakout: bool = False):
# Eingefrorener TF/Mult vom Aktivieren — kein Wechsel mitten im Trade
with self._lock:
tf = self._active_tf
mult = self._trade_mult
if tf is None:
tf = self._choose_atr_tf()
if not self._refresh_atr(sym, tf):
return
with self._lock:
raw_atr = self._atr
atr = max(raw_atr, _ATR_MIN)
if mult is None:
mult = self._trail_mult()
if raw_atr < _ATR_MIN:
# gedrosselt (max. 1×/5 min) — lief vorher je Modify-Tick und flutete
# das Log (~35k Zeilen je 5-MB-Rotation in Niedrig-Vola-Phasen)
now_ts = time.time()
if now_ts - getattr(self, "_floor_warn_ts", 0.0) > 300:
self._floor_warn_ts = now_ts
log.warning(f"ATR({raw_atr:.4f}) < Floor({_ATR_MIN}) — Trailing nutzt Minimum")
positions = mt5.positions_get(ticket=ps["ticket"])
if not positions:
return
pos = positions[0]
si = mt5.symbol_info(sym)
if not si:
return
cur = bid if is_long else ask
entry = float(pos.price_open)
cur_sl = float(pos.sl) if pos.sl else 0.0
cur_tp = float(pos.tp) if pos.tp else 0.0
# min_dist: Broker-Mindestabstand + Spread + 5 Punkte Puffer, mindestens 1 Cent
spread = getattr(si, "spread", 0) or 0
min_dist = max((si.trade_stops_level + spread + 5) * si.point, 0.01)
profit = (cur - entry) if is_long else (entry - cur)
# ── Manuelle SL/TP-Übersteuerung erkennen (Fix 2026-07-17) ───────────────
# Hat jemand SL/TP EXTERN geändert — v. a. direkt im MT5-Terminal, wo der
# App-Pfad `deactivate()` NICHT greift — d. h. der Broker-Wert weicht von dem
# ab, was das Trailing zuletzt gesetzt hat? Dann Finger weg: Trailing abschalten,
# sonst überschreibt es die Handeingabe (real: TP 90,000 → 81,484). Erst ab dem
# 2. Modify aktiv (`_last_*` gesetzt); Reset bei Aktivierung verhindert Altwert-
# Fehlalarme. Deckt sich mit dem App-Verhalten (manuelles SL/TP = Trailing aus).
with self._lock:
last_sl = self._last_sl
last_tp = self._last_tp
tol = max(_SL_THRESHOLD_PTS * si.point, atr * _SL_THRESHOLD_ATR)
ext_sl = last_sl is not None and cur_sl and abs(cur_sl - last_sl) > tol
ext_tp = last_tp is not None and cur_tp and abs(cur_tp - last_tp) > tol
if ext_sl or ext_tp:
with self._lock:
self.enabled = False
self._high_water = None
self._phase = ""
self._hw_at_last_modify = None
self._active_tf = None
self._trade_mult = None
self._partial_done = False
log.info(f"Manuelle SL/TP-Änderung erkannt (SL {cur_sl:.{si.digits}f}"
f"{(last_sl or 0):.{si.digits}f} · TP {cur_tp:.{si.digits}f}"
f"{(last_tp or 0):.{si.digits}f}) → Trailing abgeschaltet, "
f"Handeingabe bleibt stehen")
return
# Gedrosselt (1×/30 s): lief je Modify-Tick → ~28k Zeilen je Log-Rotation,
# die 5×5-MB-Rotation deckte nur noch ~2 Tage Forensik ab (Fix 2026-07-19)
now_dbg = time.time()
if now_dbg - getattr(self, "_dbg_ts", 0.0) > 30:
self._dbg_ts = now_dbg
log.debug(
f"Trail: profit={profit:.3f} ATR={atr:.4f}×{mult:.1f}"
f" HW={hw:.3f} SL={cur_sl:.5f} phase={self._phase}"
+ (" [AUSBRUCH]" if breakout else ""))
# ── Teil-Exit / Runner: einmalig die Hälfte bei 1.5×ATR sichern ──────
# Bankt realen Gewinn ohne den Trade abzuwürgen — der Rest läuft mit
# dem weiten ATR-Trail weiter (Breakeven ist bei 1.5×ATR bereits aktiv,
# der Runner ist damit risikofrei).
with self._lock:
partial_done = self._partial_done
if _PARTIAL_TP_FRAC > 0 and not partial_done and profit >= _PARTIAL_TP_ATR * atr:
vol, info = self.trader.partial_close_position(
pos, si, _PARTIAL_TP_FRAC, "partial")
if vol > 0:
with self._lock:
self._partial_done = True
log.info(f"[Teil-Exit] {vol:.2f}L @ {info:.3f} gesichert "
f"(Profit {profit:.3f}{_PARTIAL_TP_ATR}×ATR)")
if self._notify:
try:
self._notify("partial", vol, float(info), profit)
except Exception as e:
log.warning(f"Teil-Exit-Notify: {e}")
else:
# nur einmal pro Trade versuchen, sonst Log-Spam bei vmin-Pos
with self._lock:
self._partial_done = True
log.info(f"[Teil-Exit] übersprungen: {info}")
# ── Drei Phasen (mit Ratsche: nie zurück) ────────────────────────────
if profit < _TRAIL_START_ATR * atr:
phase = "Init"
elif profit < _PHASE4_ATR * atr:
phase = "Trail"
else:
phase = "Lock"
# Phasen-Ratsche: ATR-Drift (neue Kerze) darf die Phase nicht
# zurückwerfen — sonst friert das Trailing in Init wieder ein
with self._lock:
prev_phase = self._phase
if _PHASE_RANK.get(prev_phase, -1) > _PHASE_RANK.get(phase, 0):
phase = prev_phase
# ── Time-Stop: nach _TIMESTOP_MIN Minuten noch in "Init" (nie ≥0,3×ATR
# gelaufen = Whipsaw-Opfer) → schließen statt bis SL bluten. Dank Phasen-
# Ratsche ist phase=="Init" exakt "HW-Profit erreichte nie den Trail-Start".
if phase == "Init" and _TIMESTOP_MIN > 0 and time.time() >= self._no_close_until:
age_s = time.time() - (pos.time - self.trader._broker_offset_s())
if age_s >= _TIMESTOP_MIN * 60:
lots = ps.get("lots", 0.0)
log.info(f"⏱ Time-Stop: {age_s/60:.0f} min ohne Fortschritt "
f"(Phase Init, Profit {profit:+.3f}) → Position schließen")
err = self.trader.close(reason="timestop")
if err:
log.warning(f"Time-Stop-Close fehlgeschlagen: {err}")
else:
with self._lock:
self.enabled = False
self._high_water = None
self._phase = ""
self._hw_at_last_modify = None
self._setup = None
self._active_tf = None
self._trade_mult = None
self._partial_done = False
return
if phase == "Init":
if cur_sl:
new_sl = cur_sl # Initial-SL beibehalten
else:
if is_long:
new_sl = max(entry - mult * atr, 0.001)
else:
new_sl = entry + mult * atr
elif phase == "Trail":
# Breakeven-Floor (SL = Entry) erst ab 1.0×ATR Profit — vorher
# stoppte jeder normale Rückläufer zum Entry mit ±0 aus (+0.08-Closes)
breakeven = profit >= _BREAKEVEN_ATR * atr
if is_long:
candidate = hw - mult * atr
if breakeven:
candidate = max(candidate, entry)
candidate = max(candidate, entry - mult * atr)
new_sl = max(candidate, cur_sl) if cur_sl else candidate
else:
candidate = hw + mult * atr
if breakeven:
candidate = min(candidate, entry)
candidate = min(candidate, entry + mult * atr)
new_sl = min(candidate, cur_sl) if cur_sl else candidate
else:
tight_mult = max(_PHASE4_MULT_MIN, mult * _PHASE4_MULT_SCALE)
if is_long:
new_sl = max(hw - tight_mult * atr, entry, cur_sl)
else:
candidate = hw + tight_mult * atr
new_sl = min(candidate, entry, cur_sl) if cur_sl \
else min(candidate, entry)
new_sl = min(new_sl, entry + tight_mult * atr)
# ── Mindest-Abstand zum aktuellen Kurs (inkl. Spread) ────────────────
if is_long:
new_sl = min(new_sl, cur - min_dist)
else:
new_sl = max(new_sl, cur + min_dist)
new_sl = max(round(new_sl, si.digits), 0.001)
# ── Phase speichern ───────────────────────────────────────────────────
with self._lock:
self._phase = phase
# ── Schwellwert und SL-Qualifizierung ────────────────────────────────
threshold = max(_SL_THRESHOLD_PTS * si.point, atr * _SL_THRESHOLD_ATR)
if is_long:
sl_ok = new_sl > cur_sl + threshold
else:
sl_ok = (not cur_sl) or (new_sl < cur_sl - threshold)
tp_missing = (cur_tp == 0.0)
# ── Trailing TP: kurz unter dem High-Water-Mark nachziehen ───────────
if phase == "Init":
# Phase 1: kein Trailing — Trade braucht Luft (fester Puffer vom Entry)
tp_send = entry + _TP_INIT_ATR * atr if is_long else entry - _TP_INIT_ATR * atr
elif phase == "Lock":
# Phase 3: sehr enges Trailing direkt am HW (0.3×ATR Abstand)
tp_send = hw - _TP_LOCK_ATR * atr if is_long else hw + _TP_LOCK_ATR * atr
else:
# Phase 2 (Trail): TP zieht mit HW mit (0.5×ATR unter dem Hoch)
tp_send = hw - _TP_TRAIL_ATR * atr if is_long else hw + _TP_TRAIL_ATR * atr
# Mindest-TP: 0.5×ATR über/unter Entry — nicht in Verlust schließen
if is_long:
tp_send = max(tp_send, entry + 0.5 * atr)
else:
tp_send = min(tp_send, entry - 0.5 * atr)
# Broker-Mindestabstand zum aktuellen Kurs einhalten
if is_long and tp_send <= cur + min_dist:
tp_send = cur + min_dist * 2
elif not is_long and tp_send >= cur - min_dist:
tp_send = cur - min_dist * 2
tp_send = round(tp_send, si.digits)
# TP-Ratsche nur in Init/Trail (nie zurückbewegen). In Phase Lock darf
# der TP näher an den Kurs rücken — Gewinnsicherung, sonst bleibt der
# weite Init-TP für immer stehen und das Lock-TP greift nie.
if cur_tp and phase != "Lock":
if is_long and cur_tp > tp_send:
tp_send = cur_tp
elif not is_long and cur_tp < tp_send:
tp_send = cur_tp
# TP-Trailing: signifikante Änderung auch ohne SL-Änderung senden
# (abs: in Lock zählt auch die Annäherung als Verbesserung)
tp_improvement = bool(cur_tp) and not tp_missing \
and abs(tp_send - cur_tp) > threshold
if not sl_ok and not tp_missing and not tp_improvement:
return
# Wenn nur TP verbessert: SL unverändert lassen
sl_to_send = new_sl if sl_ok else cur_sl
# ── order_send ────────────────────────────────────────────────────────
res = mt5.order_send({
"action": mt5.TRADE_ACTION_SLTP,
"symbol": sym,
"position": pos.ticket,
"sl": sl_to_send,
"tp": tp_send,
})
if res and res.retcode == mt5.TRADE_RETCODE_DONE:
with self._lock:
self._last_sl = sl_to_send
self._last_tp = tp_send
self._last_modify_ts = time.time()
self._hw_at_last_modify = hw
improvement = abs(sl_to_send - cur_sl) if cur_sl else abs(sl_to_send - entry)
tp_log = ""
if tp_missing:
tp_log = f" TP={tp_send:.{si.digits}f} (neu)"
elif tp_improvement:
tp_log = f" TP {cur_tp:.{si.digits}f}{tp_send:.{si.digits}f}"
log.info(
f"[{phase}{'*' if breakout else ''}]"
f" SL {cur_sl:.{si.digits}f}{sl_to_send:.{si.digits}f}"
f" Δ{improvement:.{si.digits}f}"
f" HW={hw:.3f} ATR={atr:.4f}×{mult:.1f}"
f" profit={profit:.3f}{tp_log}")
else:
rc = res.retcode if res else "None"
log.error(f"SLTP-Fehler rc={rc} SL={sl_to_send} TP={tp_send}")
with self._lock:
self._last_modify_ts = time.time() - _MODIFY_COOLDOWN_S + _ERROR_COOLDOWN_S
def deactivate(self) -> bool:
"""Trailing sofort abschalten (z. B. bei manuellem SL/TP durch den User —
sonst überschriebe das Trailing den Wert beim nächsten Tick). Rückgabe =
ob es vorher aktiv war."""
with self._lock:
was = self.enabled
self.enabled = False
self._high_water = None
self._ticks = 0
self._phase = ""
self._hw_at_last_modify = None
self._active_tf = None
self._trade_mult = None
self._partial_done = False
return was
# ── Toggle ────────────────────────────────────────────────────────────────
def toggle(self, sym: str) -> bool:
with self._lock:
new_state = not self.enabled
if new_state:
tf = self._choose_atr_tf()
with mt5_lock() as got:
if got:
self._refresh_atr(sym, tf)
with self._lock:
self.enabled = True
self._high_water = None
self._ticks = 0
self._phase = "Init"
# Kurze Wartezeit (2s) statt vollen Cooldown (8s) nach Aktivierung
self._last_modify_ts = time.time() - _MODIFY_COOLDOWN_S + _ACTIVATE_DELAY_S
self._hw_at_last_modify = None
# TF + Mult für den gesamten Trade einfrieren — die Auto-Wahl
# wechselte sonst mitten im Trade und warf die Phase zurück
self._active_tf = tf
self._trade_mult = self._trail_mult()
self._partial_done = False
# Referenz für die Extern-Änderungs-Erkennung neu starten — sonst
# würde der Alt-SL/-TP des Vortrades als „manuelle Änderung" gelesen
self._last_sl = None
self._last_tp = None
atr = self._atr
mult = self._trade_mult
effective_atr = max(atr, _ATR_MIN) if atr else None
log.info(
f"AKTIVIERT ATR={effective_atr:.4f} ({_TF_LABELS.get(tf, '?')})"
f" mult={mult:.1f}x" if effective_atr
else "AKTIVIERT (ATR wird beim ersten Tick berechnet)")
return True
else:
with self._lock:
self.enabled = False
self._high_water = None
self._ticks = 0
self._phase = ""
self._hw_at_last_modify = None
self._active_tf = None
self._trade_mult = None
self._partial_done = False
log.info("DEAKTIVIERT")
return False
# ── Snapshot für UI ───────────────────────────────────────────────────────
def snapshot(self) -> dict:
with self._lock:
raw_atr = self._atr
tf = self._atr_tf
override = self._atr_tf_override
return {
"enabled": self.enabled,
"atr": max(raw_atr, _ATR_MIN) if raw_atr else None,
"atr_raw": raw_atr,
"atr_tf": _TF_LABELS.get(tf, "?"),
"atr_tf_override": _TF_LABELS.get(override) if override is not None else None,
"high_water": self._high_water,
"last_sl": self._last_sl,
"last_tp": self._last_tp,
"trail_mult": self._trade_mult if self._trade_mult is not None
else self._trail_mult(),
"phase": self._phase,
}
+201
View File
@@ -0,0 +1,201 @@
"""
core/tu_rating.py — Traders-Union-Analyse (tradersunion.com)
=============================================================
Holt die technische Analyse für WTI von der öffentlichen Traders-Union-API
(quotes.tradersunion.com) — dieselben Daten, die der Tacho auf
https://tradersunion.com/currencies/forecast/wti-crude-oil/signals/ anzeigt.
API: GET /api/v3/informer/technical-analysis/detailed/?symbol=WTI/USD
Liefert pro Zeitebene (m5, m15, m30, h1, h4, d1, w1):
- forecast → Gesamt-Verdikt ("Strong Sell""Strong Buy")
- ta → Oszillator-Zähler (buy/sell/neutral, 13 Indikatoren)
- ma → Moving-Average-Zähler (MA5MA200, SMA+EMA)
- indicators → 14 Einzelindikatoren mit Werten
Auto-Trader-Signal (trade_signal):
Verdikt je TF → Score (-2 Strong Sell … +2 Strong Buy)
LONG wenn m15 ≥ +1 UND m30 ≥ +1 UND h1 nicht dagegen (≥ 0)
SHORT spiegelbildlich. Konfidenz steigt mit Strong-Verdikten und
H1-/M5-Bestätigung. Daten älter als 5 min → WARTEN (kein Stale-Trading).
Kein API-Key nötig. fetch() blockiert (HTTP) — im Hintergrund-Thread rufen;
snapshot()/trade_signal() liefern den letzten Stand sofort.
"""
from __future__ import annotations
import threading
import time
from core.logger import get_logger
log = get_logger("turating")
_API_URL = ("https://quotes.tradersunion.com/api/v3/"
"informer/technical-analysis/detailed/")
_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/125.0 Safari/537.36")
# Zeitebenen wie auf der Website: Anzeige-Key → API-Key
INTERVALS: dict[str, str] = {
"5m": "m5",
"15m": "m15",
"30m": "m30",
"1h": "h1",
"4h": "h4",
"1d": "d1",
"1w": "w1",
}
# Verdikt → numerischer Score (für Nadel + Trading-Logik)
_VERDICT_SCORE = {
"strong sell": -2, "sell": -1, "neutral": 0, "buy": 1, "strong buy": 2,
}
_STALE_S = 300 # Daten älter als 5 min → kein Trading-Signal
def _vscore(verdict: str | None) -> int:
return _VERDICT_SCORE.get((verdict or "").strip().lower(), 0)
def _counts_rating(c: dict) -> float | None:
"""Nadel-Position -1..+1 aus Buy/Sell/Neutral-Zählern."""
total = (c.get("buy") or 0) + (c.get("sell") or 0) + (c.get("neutral") or 0)
if not total:
return None
return ((c.get("buy") or 0) - (c.get("sell") or 0)) / total
class TradersUnionProvider:
"""Holt + cached die Traders-Union-Analyse für ein Symbol."""
def __init__(self, symbol: str = "WTI/USD"):
self.symbol = symbol
self._lock = threading.Lock()
self._busy = False
self._data: dict = {} # {interval_key: {...}}
self._error: str | None = None
self._last_update: float | None = None
# ── HTTP-Fetch (blockierend — im Hintergrund-Thread aufrufen) ───────────
def fetch(self) -> bool:
with self._lock:
if self._busy:
return False
self._busy = True
try:
import requests
resp = requests.get(
_API_URL, params={"symbol": self.symbol},
headers={"User-Agent": _UA, "Accept": "application/json"},
timeout=15)
resp.raise_for_status()
payload = resp.json().get("data") or {}
data = {}
for key, api_key in INTERVALS.items():
tf = payload.get(api_key)
if not isinstance(tf, dict):
continue
osc = tf.get("ta") or {}
ma = tf.get("ma") or {}
c_osc = {"buy": osc.get("buy", 0),
"sell": osc.get("sell", 0),
"neutral": osc.get("neutral", 0)}
c_ma = {"buy": ma.get("buy", 0),
"sell": ma.get("sell", 0),
"neutral": ma.get("neutral", 0)}
total = {k: c_osc[k] + c_ma[k] for k in c_osc}
data[key] = {
"verdict": tf.get("forecast") or "",
"verdict_osc": osc.get("forecast") or "",
"verdict_ma": ma.get("forecast") or "",
"rating": _counts_rating(total),
"rating_osc": _counts_rating(c_osc),
"rating_ma": _counts_rating(c_ma),
"counts": total,
"counts_osc": c_osc,
"counts_ma": c_ma,
"score": _vscore(tf.get("forecast")),
}
if not data:
raise ValueError(f"keine TF-Daten für {self.symbol}")
with self._lock:
self._data = data
self._error = None
self._last_update = time.time()
log.info(f"{self.symbol}: " + " ".join(
f"{k}={d['verdict']}" for k, d in data.items()))
return True
except Exception as e:
with self._lock:
self._error = str(e)[:120]
log.warning(f"TU-Analyse Fetch fehlgeschlagen: {e}")
return False
finally:
with self._lock:
self._busy = False
# ── Snapshot für UI (Tachos) ─────────────────────────────────────────────
def snapshot(self) -> dict:
with self._lock:
return {
"symbol": self.symbol,
"intervals": dict(self._data),
"error": self._error,
"last_update": self._last_update,
"busy": self._busy,
}
# ── Trading-Signal für den Auto-Trader ───────────────────────────────────
def trade_signal(self) -> dict:
"""
Konsens-Signal aus der Traders-Union-Analyse:
LONG: m15 ≥ +1 und m30 ≥ +1 und h1 ≥ 0
SHORT: m15 ≤ 1 und m30 ≤ 1 und h1 ≤ 0
Konfidenz: 60 % Basis, +10 je Strong-Verdikt (m15/m30),
+10 bei H1-Bestätigung, +5 bei M5-Bestätigung (max 90).
"""
with self._lock:
data = dict(self._data)
ts = self._last_update
base = {"signal": "WARTEN", "conf_pct": 0, "score": 0.0,
"setup": "TU_KONSENS", "regime": None, "rsi": None,
"reasons": []}
if not data:
base["reasons"] = ["keine TU-Daten"]
return base
if not ts or time.time() - ts > _STALE_S:
base["reasons"] = ["TU-Daten veraltet"]
return base
sc = {k: data.get(k, {}).get("score", 0) for k in
("5m", "15m", "30m", "1h")}
reasons = [f"{k}: {data[k]['verdict']}" for k in
("5m", "15m", "30m", "1h", "4h", "1d") if k in data]
signal = "WARTEN"
if sc["15m"] >= 1 and sc["30m"] >= 1 and sc["1h"] >= 0:
signal = "LONG"
elif sc["15m"] <= -1 and sc["30m"] <= -1 and sc["1h"] <= 0:
signal = "SHORT"
conf = 0
if signal != "WARTEN":
d = 1 if signal == "LONG" else -1
# Basis 60: mit H1-Bestätigung (+10) erreicht ein sauberer
# Konsens die Auto-Schwelle (70) auch ohne Strong-Verdikt
conf = 60
conf += 10 * sum(1 for k in ("15m", "30m") if sc[k] * d >= 2)
if sc["1h"] * d >= 1:
conf += 10
if sc["5m"] * d >= 1:
conf += 5
conf = min(conf, 90)
# Score -1..+1 (Mittel der Trading-TFs, normiert auf ±2)
score = (sc["15m"] + sc["30m"] + sc["1h"]) / 6.0
return {"signal": signal, "conf_pct": conf, "score": score,
"setup": "TU_KONSENS", "regime": None, "rsi": None,
"reasons": reasons}
+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
+46
View File
@@ -0,0 +1,46 @@
# Exit-Simulation (Befund B: Initial-SL-Breite)
`backtest_exit.py` — misst, ob ein **weiterer Initial-SL** netto mehr bringt,
unter originalgetreuer Nachbildung des Exit-Ablaufs aus `core/trailing.py`.
## Mechanik (nachgebildet)
- Initial-SL = **X×ATR** (getestete Variable).
- **Teil-Exit 50 %** bei +1,5×ATR (einmalig).
- Phasen: **Init** (<0,3×ATR → Initial-SL halten) · **Trail** (SL = HW∓1,5×ATR,
Breakeven-Floor ab +0,6×ATR) · **Lock** (≥3,5×ATR → enger). Phasen-Ratsche.
- Pessimistische Intrabar-Annahme: **Gegenlauf vor Mitlauf** → überschätzt den
Nutzen eines weiten SL NICHT.
- PnL in **R** (ATR-Vielfache). Fester Init-TP (+3,5×ATR) weggelassen — der
Runner-Exit läuft praktisch über den Trailing-SL (konservativ, dominant).
> Schlüssel: Der weite Init-SL wirkt **nur in der Init-Phase**. Sobald Trailing
> greift, kappt HW∓1,5×ATR ihn ohnehin. Er schützt also genau die Trades, die
> **sofort** gegen den Einstieg laufen (die „Frühstopps").
## Ergebnis (M5+M30, ~28 Tage, 3585 Signale)
| Init-SL | Treffer | Ø-R | Summe-R | PF | Ø-Verlierer | Worst-R | Init-Stops |
|--------:|--------:|----:|--------:|---:|-----------:|--------:|-----------:|
| 1,2 | 37 % | 0,169 | 607 | 1,33 | 1,09 | 1,20 | 37 % |
| 1,5 | 39 % | 0,182 | 654 | 1,33 | 1,29 | 1,50 | 31 % |
| **2,0** | **43 %** | **0,223** | **798** | **1,39** | 1,56 | 2,00 | 25 % |
| 2,5 | 46 % | 0,255 | 914 | 1,43 | 1,78 | 2,50 | 21 % |
| 4,0 | 51 % | 0,308 | 1104 | 1,49 | 2,28 | 4,00 | 14 % |
**Befund:** Ø-R, PF, Treffer steigen monoton mit der SL-Breite, Frühstopps sinken.
**Aber:** der größte Einzelverlust wächst 1:1 (Worst-R = X) → Tail-/Drawdown-Risiko.
## Bewertung
- Das aktuelle Band **1,21,5×ATR ist zu eng** (deckt sich mit MAE ~1,7×ATR und
37 %/31 % Frühstopps). Wechsel auf **~2,0×ATR** ist der ausgewogene Punkt:
+32 % Ø-R, PF 1,33→1,39, Frühstopps 37→25 %, Worst-Case auf 2,0×ATR begrenzt.
- **Nicht** auf 34×ATR: Durchschnitte steigen weiter, aber die großen Einzel-
verluste treiben Drawdown/Ruin-Risiko; Stichprobe ist zudem ein Trend-Regime
(„länger halten" bevorzugt).
-**Money-Management:** ein weiterer SL = mehr Risiko/Trade in EUR bei gleicher
Lotgröße. Für konstantes EUR-Risiko die Lots ~proportional senken (1,5→2,0 ≈ 25 %).
## Aufruf
`python backtest_exit.py [n_bars]` (Default 8000 M5-Bars)
+68
View File
@@ -0,0 +1,68 @@
# Gesamtempfehlung (Verdict-Panel)
Grafischer Empfehlungsbereich ganz oben im Mobile-Dashboard (`web/`), der die
Module zu **einer** Aussage zusammenfasst.
## Wer berechnet was
- **Backend (`core/engine.py``_verdict`)** baut die Aggregation und legt sie
als `verdict` in den Snapshot (`/api/snapshot`, `/ws`). Eine Quelle der
Wahrheit — das Frontend rendert nur.
- **Frontend (`web/app.js``renderVerdict`)** zeichnet Ring, Bias-Balken und
Modul-Chips. Markup in `web/index.html` (`#card-verdict`), Stil in
`web/style.css` (`.vd-*`).
## Logik
**Headline = die validierte Welle** (`wave_signal`) — das einzige Modul mit per
Backtest belegtem Edge. Sie bestimmt LONG/SHORT/WARTEN und die Order-Freigabe.
Die übrigen Module bilden nur einen **gewichteten Konsens** (Anzeige, kein
Eingriff in die Order-Logik).
Votes je Modul: `+1` LONG · `1` SHORT · `0` neutral. Gewichte:
| Modul | Gewicht | Quelle |
|-------|---------|--------|
| Welle | **3,0** | `wave_signal.signal` (validierter Edge) |
| M30 | 1,5 | `wave.htf_trend` (EMA12/50 der M30 — exakt das Signal, das die Welle nutzt) |
| H1 | 1,5 | `wave.h1_trend` (EMA12/50 der H1) |
| KI-Copilot | 1,0 *(nur mit Bias)* | `agent.advisory.bias` — ohne advisory/Bias **Gewicht 0** |
| Elliott/FVG | 1,0 *(nur mit Ziel)* | Richtung zum `elliott.target` — ohne Ziel **Gewicht 0** |
| Squeeze | 2,0 *(nur `active`)* | `wave.squeeze` — armed/keine Kompression **Gewicht 0** |
(TradersUnion wurde 2026-07-06 aus dem Konsens entfernt — nur noch Anzeige.)
```
bias = Σ(gewicht · vote) / Σ(gewicht) → 1 … +1
```
**Stimmberechtigt sind nur Module mit Aussage** (Fix 2026-07-15): Module ohne
anwendbares Signal (Squeeze ohne Ausbruch, Elliott ohne Ziel, KI aus) bekommen
Gewicht 0 und stehen NICHT im Nenner — vorher dämpfte z. B. das fast immer
neutrale Squeeze-Gewicht (2,0) die Nadel dauerhaft um ~×0,8 Richtung Mitte.
„Flach" bei Welle/M30/H1 bleibt dagegen eine ECHTE Neutral-Stimme (mit Gewicht).
`bias` steuert die **Nadel** im Balken (1 = ganz SHORT/links, +1 = ganz
LONG/rechts: `left% = (bias+1)/2·100`). `agree/total` = wie viele der **anderen**
stimmberechtigten Module (ohne die Welle selbst — die zählte sich bis 2026-07-15
mit) die Headline-Richtung teilen. Der **Konfidenz-Ring** zeigt `wave.conf_pct`
als conic-gradient in Richtungsfarbe.
> ⚠ Wichtig: Für M30/H1 werden die **EMA12/50-Signale der Welle** genutzt
> (`htf_trend`/`h1_trend`), NICHT das Regressions-Winkelmaß `market.angles` —
> die beiden können sich widersprechen, das Panel muss zur Empfehlung passen.
## Snapshot-Feld `verdict`
```json
{
"headline": "SHORT", "conf": 90, "bias": -1.0, "agree": 3, "total": 3,
"votes": [{"name": "Welle", "vote": -1, "weight": 3.0, "detail": "SHORT · 90%"}, ]
}
```
## Pflege
Neues Modul ins Panel → Vote/Gewicht in `_verdict` ergänzen; das Frontend rendert
`votes` generisch. Bei JS/CSS-Änderung Asset-Version `v=N` in `index.html`
hochzählen. Siehe `CLAUDE.md`.
+54
View File
@@ -0,0 +1,54 @@
# Risiko-Management (Positionsgröße & Stops)
Stand: 2026-07-08.
## Positionsgröße
> **Aktiv (User-Vorgabe): margin-basiert 95 %.** `calc_lots` setzt die Lots auf
> ~95 % der freien Margin (`[trading] margin_buffer_pct=95`, `risk_pct=0`). ⚠ Das
> ist faktisch All-in: **~7 %+ Konto-Risiko pro Trade** (gemessen
> `backtest_sizing.py`: 36 % Wahrscheinlichkeit eines >80 %-Drawdowns). Bewusste
> Entscheidung — 2026-07-06 kurz auf risiko-basiert 1,5 % gestellt, dann auf
> 95 % Margin zurück. Der harte Broker-SL wird trotzdem immer gesetzt.
Das **risiko-basierte** Sizing (`calc_lots_risk`) ist gebaut, gemessen und
jederzeit aktivierbar (`[trading] risk_pct = 1.5` + Restart). Es wählt die Lots
so, dass der **Verlust beim Initial-SL ≈ `risk_pct` % der Equity** ist:
```
val_per_price = tick_value / tick_size # € je 1.0 Preis je 1 Lot
lots = (Equity × risk_pct/100) / (SL_Distanz × val_per_price)
```
- **SL zuerst:** `_send_locked` berechnet erst den SL (`_calc_sl_tp`, Band
1,82,2×ATR), dann die Lots aus der **echten** SL-Distanz.
- **Margin bleibt Deckel:** nie mehr Lots als die freie Margin (× Buffer) erlaubt.
- **Fallback:** liefert `calc_lots_risk` 0 (fehlende Daten), greift `calc_lots`.
- Vergleich (102-€-Konto, SL 2×ATR≈0,53): 95 % Margin ≈ 0,15 L → 7 € (6,8 %)
am SL · 1,5 % Risiko ≈ 0,03 L → 1,40 € (1,4 %).
## Stops & Auto-Close
- **Harter Broker-SL auf jeder Bot-Order** (`req["sl"]`) — server-seitig, wirkt
auch bei Bot-/Verbindungsausfall. Band 1,82,2×ATR (Ziel 2,0; gemessen
`backtest_exit.py`); Breakeven ab +1,3×ATR; Trailing Init/Trail/Lock
(Parameter gemessen optimal, `backtest_trailing.py`; ATR-Floor 0,06,
`backtest_atrfloor.py`).
- **Adoptierte Fremd-Trades** (in MT5 manuell eröffnet) bekommen beim Erkennen
einen **Schutz-SL** nachgerüstet (`_refresh_locked`), falls keiner gesetzt ist.
- **Notfall-Stop (P&L ≤ Wert) + Gewinn-Ziel (P&L ≥ +Wert):** server-seitiger
Auto-Close (`engine._check_auto_close`, 1-s-Loop, wirkt bei gesperrtem Handy).
Notfall-Stop wird beim Öffnen automatisch auf den letzten Wert armiert
(Start `[trading] auto_emergency_loss`, Default 10 — ⚠ bei ~100-€-Konto sind
10 € ≈ 10 % Equity, das ist der faktische Risiko-Deckel). „aus"-Button
deaktiviert auch den Auto-Arm.
- **SL/TP sind im Snapshot + Web sichtbar** (Position-Kachel) — fehlender SL wird
rot „⚠ kein SL gesetzt!" markiert.
## Bewusst NICHT umgesetzt
- **Tagesverlust-Schalter / Circuit Breaker:** vom User **abgelehnt** — nicht
einbauen (s. CLAUDE.md).
- **Signal-Filter als Risiko-Schutz:** 5× gemessen wirkungslos/schädlich
(Winkel, HTF-Winkel, Konfluenz-Strafe, Gegen-H1-Reversal, ER-Chop-Gate).
Schutz läuft über Stops + Sizing, nicht über das Signal.
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Sucht ungefüllte Kurslücken (Gaps) im WTI-Tageschart 2026.
Gap = Vakuum zwischen Vortag und Folgetag:
Gap UP : Low(heute) > High(gestern) → Lücke [High_gestern … Low_heute]
Gap DOWN : High(heute) < Low(gestern) → Lücke [High_heute … Low_gestern]
„Gefüllt" = ein SPÄTERER Bar handelt wieder in/durch das Vakuum (Kurs kehrt zur
Gap-Kante zurück). Ausgegeben werden die NOCH OFFENEN (ungefüllten) Gaps + das
Kursniveau, das sie schließen würde, und der Abstand zum aktuellen Kurs.
"""
import datetime as dt
import MetaTrader5 as mt5
OFF = 3 * 3600 # Broker UTC+3 → UTC (nur für die Datumsanzeige grob)
def main():
if not mt5.initialize():
print("MT5 init fehlgeschlagen:", mt5.last_error()); return
sym = None
for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD"):
if mt5.symbol_info(c): sym = c; break
# genug D1-Bars holen (ganzes Jahr + Puffer)
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_D1, 0, 400)
tick = mt5.symbol_info_tick(sym)
price = (tick.bid + tick.ask) / 2 if tick else None
mt5.shutdown()
if bars is None:
print("Keine Bars"); return
rows = []
for b in bars:
d = dt.datetime.utcfromtimestamp(int(b["time"]) - OFF).date()
rows.append({"date": d, "o": float(b["open"]), "h": float(b["high"]),
"l": float(b["low"]), "c": float(b["close"])})
rows = [r for r in rows if r["date"].year == 2026]
if len(rows) < 2:
print("Zu wenig 2026-Daten"); return
gaps = []
for i in range(1, len(rows)):
p, cur = rows[i - 1], rows[i]
if cur["l"] > p["h"]: # Gap UP
gaps.append({"i": i, "date": cur["date"], "dir": "UP",
"lo": p["h"], "hi": cur["l"], "ref_close": p["c"]})
elif cur["h"] < p["l"]: # Gap DOWN
gaps.append({"i": i, "date": cur["date"], "dir": "DOWN",
"lo": cur["h"], "hi": p["l"], "ref_close": p["c"]})
# Fill-Status: handelt ein späterer Bar in das Vakuum zurück?
for g in gaps:
later = rows[g["i"] + 1:]
if g["dir"] == "UP":
# gefüllt, sobald ein späterer Low <= Gap-Unterkante (zurück in die Lücke)
mn = min((r["l"] for r in later), default=price or g["hi"])
g["filled"] = mn <= g["lo"]
g["fill_target"] = g["lo"] # Niveau, das die Lücke schließt
g["partial"] = (not g["filled"]) and mn < g["hi"]
else:
mx = max((r["h"] for r in later), default=price or g["lo"])
g["filled"] = mx >= g["hi"]
g["fill_target"] = g["hi"]
g["partial"] = (not g["filled"]) and mx > g["lo"]
g["size"] = g["hi"] - g["lo"]
openg = [g for g in gaps if not g["filled"]]
print("=" * 78)
print(f" WTI ({sym}) 2026 — Gap-Analyse (D1) aktueller Kurs ≈ {price:.2f}"
if price else f" WTI ({sym}) 2026 — Gap-Analyse (D1)")
print(f" {len(rows)} Handelstage · {len(gaps)} Gaps gesamt · "
f"{len(openg)} NOCH OFFEN")
print("=" * 78)
if not openg:
print(" Keine offenen Gaps — alle 2026er Lücken wurden gefüllt.")
return
print(f" {'Datum':<12}{'Richtg':<7}{'Lücke vonbis':<18}{'Größe':>7}"
f"{'Schließt bei':>14}{'Abstand':>10} Status")
for g in sorted(openg, key=lambda x: x["date"]):
dist = (g["fill_target"] - price) if price else 0.0
tag = "teilw. angelaufen" if g.get("partial") else "unberührt"
zone = f"{g['lo']:.2f}{g['hi']:.2f}"
print(f" {g['date'].isoformat():<12}{g['dir']:<7}{zone:<18}"
f"{g['size']:>7.2f}{g['fill_target']:>14.2f}{dist:>+10.2f} {tag}")
print("\n „Schließt bei\" = Kursniveau, das die Lücke füllt · "
"Abstand = von dort zum aktuellen Kurs (+ = darüber, = darunter)")
if __name__ == "__main__":
main()
+249
View File
@@ -0,0 +1,249 @@
//+------------------------------------------------------------------+
//| SR_Levels.mq5 |
//| Zeichnet Support-/Resistance-Linien + Zonen (Ranges), die der |
//| Python-Bot (Oil Trading Server) nach MQL5\Files\sr_levels.csv |
//| schreibt. Das Python-MetaTrader5-Paket kann selbst KEINE Chart- |
//| Objekte erzeugen — daher diese Datei-Bridge. |
//| |
//| Installation: |
//| 1) Diese Datei nach <Terminal>\MQL5\Indicators\ kopieren. |
//| 2) In MetaEditor öffnen und mit F7 kompilieren. |
//| 3) Im Navigator auf den WTI-Chart (SpotCrude) ziehen. |
//| 4) Der Bot muss laufen (schreibt die CSV alle ~5 s). |
//+------------------------------------------------------------------+
#property copyright "Oil Trading Bot"
#property version "1.10"
#property indicator_chart_window
#property indicator_plots 0
input string InpFile = "sr_levels.csv"; // Datei in MQL5\Files
input int InpRefreshS = 2; // Aktualisierung (Sekunden)
input color InpResColor = clrDodgerBlue; // Widerstand (blau kräftig)
input color InpSupColor = clrDeepSkyBlue; // Support (blau hell)
input bool InpShowRange = false; // dynamische Handels-Range zeichnen (aus per User-Wunsch)
input int InpRangeBars = 50; // Range = Hoch/Tief der letzten N Kerzen
input color InpRangeColor = clrGoldenrod; // Range-Farbe
input bool InpRangeFill = false; // Range füllen (aus = nur Umriss)
input bool InpShowChannel = true; // Regressionskanal (M30) zeichnen
input color InpChanColor = clrYellow; // Kanal-Farbe
const string PFX = "SRB_"; // Präfix S/R-Linien (aus Datei)
const string RPFX = "SRR_"; // Präfix dynamische Range (aus Chart)
string g_last = "\x01"; // letzter Datei-Inhalt (gegen Flackern)
bool g_range_drawn = false; // hat DIESE Instanz die Range gezeichnet?
//+------------------------------------------------------------------+
int OnInit()
{
EventSetTimer(MathMax(1, InpRefreshS));
Redraw();
DrawRange();
return(INIT_SUCCEEDED);
}
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
{
EventKillTimer();
ObjectsDeleteAll(0, PFX);
ObjectsDeleteAll(0, RPFX);
ChartRedraw();
}
//+------------------------------------------------------------------+
void OnTimer() { Redraw(); DrawRange(); RepositionLabels(); }
//+------------------------------------------------------------------+
//| Zeitpunkt der horizontalen Chart-Mitte (sichtbarer Bereich) |
//+------------------------------------------------------------------+
datetime CenterTime()
{
long first = ChartGetInteger(0, CHART_FIRST_VISIBLE_BAR);
long width = ChartGetInteger(0, CHART_WIDTH_IN_BARS);
int mid = (int)MathMax(0, first - width / 2);
return iTime(_Symbol, PERIOD_CURRENT, mid);
}
//+------------------------------------------------------------------+
//| Hält die S/R-Beschriftungen beim Scrollen/Zoomen mittig |
//+------------------------------------------------------------------+
void RepositionLabels()
{
datetime ct = CenterTime();
int total = ObjectsTotal(0, 0, OBJ_TEXT);
for(int i = 0; i < total; i++)
{
string nm = ObjectName(0, i, 0, OBJ_TEXT);
if(StringFind(nm, PFX + "T") == 0)
{
double pr = ObjectGetDouble(0, nm, OBJPROP_PRICE);
ObjectMove(0, nm, 0, ct, pr);
}
}
ChartRedraw();
}
//+------------------------------------------------------------------+
//| Dynamische Handels-Range = Hoch/Tief der letzten N Kerzen des |
//| AKTUELLEN Charts (TF-genau). In-place via ObjectMove → kein |
//| Flackern, passt sich an M1/M5/…/H1 automatisch an. |
//+------------------------------------------------------------------+
void DrawRange()
{
string nm = RPFX + "BOX";
if(!InpShowRange)
{
// Nur löschen, was DIESE Instanz gezeichnet hat — sonst kämpfen zwei
// Instanzen (eine an/eine aus) um die Box → Blinken im 2-s-Takt.
if(g_range_drawn) { ObjectDelete(0, nm); g_range_drawn = false; ChartRedraw(); }
return;
}
int total = Bars(_Symbol, PERIOD_CURRENT);
if(total < 5) return;
int n = InpRangeBars;
if(n > total - 1) n = total - 1;
int hi_i = iHighest(_Symbol, PERIOD_CURRENT, MODE_HIGH, n, 0);
int lo_i = iLowest (_Symbol, PERIOD_CURRENT, MODE_LOW, n, 0);
if(hi_i < 0 || lo_i < 0) return;
double hi = iHigh(_Symbol, PERIOD_CURRENT, hi_i);
double lo = iLow (_Symbol, PERIOD_CURRENT, lo_i);
datetime t0 = iTime(_Symbol, PERIOD_CURRENT, n - 1);
datetime t1 = iTime(_Symbol, PERIOD_CURRENT, 0) + PeriodSeconds();
if(ObjectFind(0, nm) < 0)
{
ObjectCreate(0, nm, OBJ_RECTANGLE, 0, t0, lo, t1, hi);
ObjectSetInteger(0, nm, OBJPROP_COLOR, InpRangeColor);
ObjectSetInteger(0, nm, OBJPROP_STYLE, STYLE_SOLID);
ObjectSetInteger(0, nm, OBJPROP_WIDTH, 1);
ObjectSetInteger(0, nm, OBJPROP_FILL, InpRangeFill);
ObjectSetInteger(0, nm, OBJPROP_BACK, true);
ObjectSetInteger(0, nm, OBJPROP_SELECTABLE, false);
ObjectSetString (0, nm, OBJPROP_TEXT, "Range " + IntegerToString(n));
}
else
{
ObjectMove(0, nm, 0, t0, lo); // linke untere Ecke
ObjectMove(0, nm, 1, t1, hi); // rechte obere Ecke
}
g_range_drawn = true;
ChartRedraw();
}
//+------------------------------------------------------------------+
//| Pflicht-Funktion jedes Indikators — hier No-op (wir zeichnen |
//| über OnTimer aus der Datei, nicht aus den Kursreihen). |
//+------------------------------------------------------------------+
int OnCalculate(const int rates_total,
const int prev_calculated,
const datetime &time[],
const double &open[],
const double &high[],
const double &low[],
const double &close[],
const long &tick_volume[],
const long &volume[],
const int &spread[])
{
return(rates_total);
}
//+------------------------------------------------------------------+
void Redraw()
{
int h = FileOpen(InpFile, FILE_READ|FILE_TXT|FILE_ANSI|FILE_SHARE_READ|FILE_SHARE_WRITE);
if(h == INVALID_HANDLE)
return; // Datei (noch) nicht da → nächster Tick
// Ganze Datei in einen String lesen und mit dem letzten Stand vergleichen —
// nur bei ÄNDERUNG neu zeichnen (sonst flackert der delete/recreate alle 2 s).
string content = "";
while(!FileIsEnding(h))
content += FileReadString(h) + "\n";
FileClose(h);
if(content == g_last)
return; // nichts geändert → nicht anfassen
g_last = content;
ObjectsDeleteAll(0, PFX);
string rows[];
int nr = StringSplit(content, '\n', rows);
int idx = 0;
for(int r = 0; r < nr; r++)
{
string line = rows[r];
if(StringLen(line) < 2) continue;
string p[];
int k = StringSplit(line, ';', p);
if(k < 2) continue;
string typ = p[0];
// 1. Zeile: SYM;<symbol> — nur auf dem passenden Chart zeichnen
if(typ == "SYM")
{
if(p[1] != _Symbol)
{
ObjectsDeleteAll(0, PFX);
ChartRedraw();
return;
}
continue;
}
// Horizontale S/R-Linien + sichtbares Text-Label an der Linie
if(typ == "R" || typ == "S")
{
double price = StringToDouble(p[1]);
if(price <= 0) continue;
string lbl = (typ=="R" ? "Widerstand " : "Unterstützung ")
+ DoubleToString(price, (int)_Digits);
color col = (typ=="R" ? InpResColor : InpSupColor);
string nm = PFX + typ + "_" + IntegerToString(idx);
if(ObjectCreate(0, nm, OBJ_HLINE, 0, 0, price))
{
ObjectSetInteger(0, nm, OBJPROP_COLOR, col);
ObjectSetInteger(0, nm, OBJPROP_STYLE, STYLE_DASH);
ObjectSetInteger(0, nm, OBJPROP_WIDTH, 1);
ObjectSetInteger(0, nm, OBJPROP_BACK, true);
ObjectSetInteger(0, nm, OBJPROP_SELECTABLE, false);
ObjectSetString (0, nm, OBJPROP_TEXT, lbl); // Tooltip
}
// Beschriftung als eigenes Text-Objekt: horizontal ZENTRIERT im sicht-
// baren Chart, direkt über der Linie (bleibt via RepositionLabels()
// auch beim Scrollen/Zoomen zentriert)
string tn = PFX + "T" + typ + "_" + IntegerToString(idx);
if(ObjectCreate(0, tn, OBJ_TEXT, 0, CenterTime(), price))
{
ObjectSetString (0, tn, OBJPROP_TEXT, lbl);
ObjectSetInteger(0, tn, OBJPROP_COLOR, col);
ObjectSetInteger(0, tn, OBJPROP_FONTSIZE, 8);
ObjectSetInteger(0, tn, OBJPROP_ANCHOR, ANCHOR_LOWER); // zentriert über dem Punkt
ObjectSetInteger(0, tn, OBJPROP_BACK, false);
ObjectSetInteger(0, tn, OBJPROP_SELECTABLE, false);
}
idx++;
}
// Regressionskanal: CH;<U|M|L>;t1;p1;t2;p2 → diagonale Trendlinie (OBJ_TREND),
// nach rechts verlängert. Anker in Broker-Zeit (= Server-Zeit im Terminal).
if(typ == "CH")
{
if(!InpShowChannel) continue;
if(k < 6) continue;
string kind = p[1];
datetime t1 = (datetime)StringToInteger(p[2]);
double pr1 = StringToDouble(p[3]);
datetime t2 = (datetime)StringToInteger(p[4]);
double pr2 = StringToDouble(p[5]);
if(pr1 <= 0 || pr2 <= 0 || t1 <= 0 || t2 <= 0) continue;
string nm = PFX + "CH_" + kind;
if(ObjectCreate(0, nm, OBJ_TREND, 0, t1, pr1, t2, pr2))
{
ObjectSetInteger(0, nm, OBJPROP_COLOR, InpChanColor);
ObjectSetInteger(0, nm, OBJPROP_STYLE, (kind == "M" ? STYLE_DOT : STYLE_DASH));
ObjectSetInteger(0, nm, OBJPROP_WIDTH, 1);
ObjectSetInteger(0, nm, OBJPROP_RAY_RIGHT, true); // nach rechts verlängern
ObjectSetInteger(0, nm, OBJPROP_RAY_LEFT, false);
ObjectSetInteger(0, nm, OBJPROP_BACK, true);
ObjectSetInteger(0, nm, OBJPROP_SELECTABLE, false);
ObjectSetString (0, nm, OBJPROP_TEXT, "Kanal " + kind);
}
continue;
}
// ("Z;"-Zonen-Zeilen schreibt der Bot nicht mehr — die dynamische Handels-
// Range zeichnet DrawRange() selbst aus den Chart-Kerzen.)
}
ChartRedraw();
}
//+------------------------------------------------------------------+
+110
View File
@@ -0,0 +1,110 @@
# Vorlage für oil_widget_config.ini — echte Datei mit Keys ausfüllen und als
# `oil_widget_config.ini` speichern (steht in .gitignore, wird NICHT committet).
# Kommentare/Erläuterungen zu den [trading]-Schaltern: siehe core/config.py.
[openai]
api_key = DEIN_OPENAI_KEY
model = gpt-4o-mini-search-preview
auto_refresh = true
refresh_min = 15
search_context = low
[news]
enabled = true
refresh_min = 10
[translation]
enabled = true
target_language = de
provider = google
show_original = false
[trading]
margin_buffer_pct = 80
risk_pct = 0
trail_timeframe = M1
last_symbol =
atr_tf = H1
wht_pct = 26.375
tf_select = heuristic
tf_min = M5
tf_max = M30
breakout_k = 0.3
entry_room_atr = 0.6
dead_hours =
auto_squeeze = true
auto_squeeze_skip_night = false
auto_squeeze_reverse = false
auto_emergency_loss = 0
auto_emergency_pct = 0
auto_emergency_margin_pct = 3.0
auto_takeprofit = 0
auto_sr_close = true
sr_close_pbreak = 0.60
export_mql5_levels = true
sr_close_min_gain = 0
sr_close_min_gain_pct = 3.0
startup_close_grace_s = 60
[gemini]
api_key = DEIN_GEMINI_KEY
model = gemini-2.0-flash
enabled = true
[telegram]
enabled = true
bot_token = DEIN_TELEGRAM_BOT_TOKEN
chat_id = DEINE_CHAT_ID
[graph]
tenant_id = DEINE_TENANT_ID
client_id = DEINE_CLIENT_ID
client_secret = DEIN_CLIENT_SECRET
report_to = trading@example.com, axel@example.com
sender = mailagent@example.com
[anthropic]
api_key =
model = claude-opus-4-8
[ollama]
base_url = http://localhost:11434
model = qwen2.5:7b
keep_alive = 30m
[agent]
enabled = true
provider = deepseek
model =
refresh_min = 5
telegram = false
[kimi]
api_key = DEIN_KIMI_KEY
base_url = https://api.moonshot.ai/v1
model = kimi-k2.6
[deepseek]
api_key = DEIN_DEEPSEEK_KEY
base_url = https://api.deepseek.com
model = deepseek-v4-flash
[zones]
demand = 74.98, 76.75, demand
support = 76.40, 77.00, support
res_low = 81.24, 82.64, resistance
fvg = 88.00, 90.00, resistance
res_top = 94.72, 94.72, resistance
invalid = 75.40, 75.80, invalidation
support_low = 72.90, 72.90, support
res_consol = 74.00, 76.00, resistance
[web]
api_token = EIN_ZUFAELLIGES_TOKEN
require_confirm = true
require_token = false
[zai]
api_key = DEIN_ZAI_KEY
base_url = https://api.z.ai/api/paas/v4
model = glm-4.5-flash
+19
View File
@@ -0,0 +1,19 @@
@echo off
REM ============================================================
REM Oil Trading Agent - Web-Backend NEU STARTEN
REM Beendet einen laufenden server.py und startet ihn frisch.
REM Nutzt absolute Pfade (PATH-unabhaengig).
REM ============================================================
title Oil Trading Server - Restart
cd /d "%~dp0"
set "PS=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
echo Beende laufenden Server ...
"%PS%" -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'python.exe' -and $_.CommandLine -like '*server.py*' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force; Write-Host (' gestoppt: PID ' + $_.ProcessId) }; Start-Sleep -Seconds 2"
echo Starte Server neu ...
set PYTHONIOENCODING=utf-8
"C:\Users\ah\AppData\Local\Programs\Python\Python312\python.exe" -X utf8 server.py
echo.
echo ===== Server beendet =====
pause
+138
View File
@@ -0,0 +1,138 @@
"""
scripts/daily_summary.py — Tages-Zusammenfassung für E-Mail
============================================================
Gibt eine HTML-Zusammenfassung aller Trades des gestrigen Tages aus.
Aufruf: python daily_summary.py [YYYY-MM-DD]
"""
import sys
import sqlite3
import datetime
from pathlib import Path
DB_PATH = Path(__file__).parent.parent / "oil_widget_history.db"
def run(target_date: datetime.date) -> str:
ts_start = int(datetime.datetime(target_date.year, target_date.month, target_date.day, 0, 0, 0).timestamp())
ts_end = ts_start + 86400
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute("""
SELECT setup, direction, pnl, closed_by, exit_time, entry_price, exit_price
FROM trades
WHERE exit_time >= ? AND exit_time < ? AND exit_time IS NOT NULL
ORDER BY exit_time ASC
""", (ts_start, ts_end))
trades = [dict(r) for r in c.fetchall()]
conn.close()
if not trades:
return f"<p>Keine Trades am {target_date.strftime('%d.%m.%Y')}.</p>"
total = len(trades)
wins = sum(1 for t in trades if (t["pnl"] or 0) > 0)
pnl_sum = sum(t["pnl"] or 0 for t in trades)
wr = wins / total * 100
# Pro Setup
setups: dict[str, dict] = {}
for t in trades:
s = t["setup"] or "NO_SETUP"
if s not in setups:
setups[s] = {"n": 0, "wins": 0, "pnl": 0.0}
setups[s]["n"] += 1
setups[s]["wins"] += 1 if (t["pnl"] or 0) > 0 else 0
setups[s]["pnl"] += t["pnl"] or 0
best = max(trades, key=lambda t: t["pnl"] or 0)
worst = min(trades, key=lambda t: t["pnl"] or 0)
def fmt_pnl(v):
sign = "+" if v >= 0 else ""
return f"{sign}{v:.2f}"
def row_color(pnl):
if pnl > 0: return "#1a4731"
if pnl < 0: return "#3d1a1a"
return "#1a1e24"
# ── HTML ──────────────────────────────────────────────────────────────────
header_color = "#1f6feb" if pnl_sum >= 0 else "#b02020"
pnl_color = "#3fb950" if pnl_sum >= 0 else "#f85149"
rows = ""
for s, d in sorted(setups.items(), key=lambda x: -x[1]["pnl"]):
wr_s = d["wins"] / d["n"] * 100
bg = row_color(d["pnl"])
rows += f"""
<tr style="background:{bg};">
<td style="padding:6px 12px;font-family:monospace;">{s}</td>
<td style="padding:6px 12px;text-align:center;">{d['n']}</td>
<td style="padding:6px 12px;text-align:center;">{wr_s:.0f}%</td>
<td style="padding:6px 12px;text-align:right;font-weight:bold;">{fmt_pnl(d['pnl'])}</td>
</tr>"""
import time as _time
best_time = _time.strftime("%H:%M", _time.localtime(best["exit_time"]))
worst_time = _time.strftime("%H:%M", _time.localtime(worst["exit_time"]))
html = f"""
<html><body style="background:#0d1117;color:#e6edf3;font-family:sans-serif;padding:24px;">
<h2 style="color:{header_color};margin-bottom:4px;">
Oil Trading &mdash; {target_date.strftime('%d.%m.%Y')}
</h2>
<table style="border-collapse:collapse;margin:16px 0;width:100%;max-width:400px;">
<tr>
<td style="padding:8px 16px;background:#161b22;color:#8b949e;">Trades</td>
<td style="padding:8px 16px;background:#161b22;font-weight:bold;">{total}</td>
</tr>
<tr>
<td style="padding:8px 16px;background:#0d1117;color:#8b949e;">Win Rate</td>
<td style="padding:8px 16px;background:#0d1117;font-weight:bold;">{wr:.0f}% ({wins}/{total})</td>
</tr>
<tr>
<td style="padding:8px 16px;background:#161b22;color:#8b949e;">PnL</td>
<td style="padding:8px 16px;background:#161b22;font-weight:bold;color:{pnl_color};font-size:18px;">{fmt_pnl(pnl_sum)}</td>
</tr>
</table>
<h3 style="color:#8b949e;margin-bottom:8px;">Pro Setup</h3>
<table style="border-collapse:collapse;width:100%;max-width:500px;">
<tr style="background:#161b22;color:#8b949e;">
<th style="padding:6px 12px;text-align:left;">Setup</th>
<th style="padding:6px 12px;">N</th>
<th style="padding:6px 12px;">WR</th>
<th style="padding:6px 12px;text-align:right;">PnL</th>
</tr>
{rows}
</table>
<table style="border-collapse:collapse;margin-top:16px;width:100%;max-width:500px;">
<tr>
<td style="padding:6px 12px;color:#8b949e;">Bester Trade</td>
<td style="padding:6px 12px;color:#3fb950;font-weight:bold;">{fmt_pnl(best['pnl'])}</td>
<td style="padding:6px 12px;color:#8b949e;font-family:monospace;">{best.get('setup','')} {best_time}</td>
</tr>
<tr>
<td style="padding:6px 12px;color:#8b949e;">Schlechtester</td>
<td style="padding:6px 12px;color:#f85149;font-weight:bold;">{fmt_pnl(worst['pnl'])}</td>
<td style="padding:6px 12px;color:#8b949e;font-family:monospace;">{worst.get('setup','')} {worst_time}</td>
</tr>
</table>
</body></html>
"""
return html
if __name__ == "__main__":
if len(sys.argv) > 1:
target = datetime.date.fromisoformat(sys.argv[1])
else:
target = datetime.date.today() - datetime.timedelta(days=1)
sys.stdout.reconfigure(encoding="utf-8")
print(run(target))
+63
View File
@@ -0,0 +1,63 @@
# send_daily_report.ps1
# Sendet täglich um 0 Uhr eine HTML-Zusammenfassung aller Trades des Vortages.
$python = "C:\Users\ah\AppData\Local\Programs\Python\Python312\python.exe"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$pyScript = Join-Path $scriptDir "daily_summary.py"
# ── Graph-Credentials aus oil_widget_config.ini ([graph]) ─────────────────
# Secrets gehören nicht ins Skript — zentrale Config wie bei den API-Keys.
$iniPath = Join-Path (Split-Path -Parent $scriptDir) "oil_widget_config.ini"
$graph = @{}
$inGraph = $false
foreach ($line in Get-Content $iniPath -Encoding UTF8) {
if ($line -match '^\s*\[(.+)\]') { $inGraph = ($Matches[1] -eq 'graph'); continue }
if ($inGraph -and $line -match '^\s*(\w+)\s*=\s*(.+?)\s*$') {
$graph[$Matches[1]] = $Matches[2]
}
}
$tenantId = $graph['tenant_id']
$clientId = $graph['client_id']
$clientSecret = $graph['client_secret']
if (-not $tenantId -or -not $clientId -or -not $clientSecret) {
Write-Error "Abschnitt [graph] in $iniPath fehlt oder ist unvollständig."
exit 1
}
# ── Zusammenfassung aus Python holen ────────────────────────────────────────
$yesterday = (Get-Date).AddDays(-1).ToString("yyyy-MM-dd")
# -join: Python gibt mehrere Zeilen aus → PowerShell macht ein String-Array daraus.
# Ohne -join würde nur die erste Zeile (<html>) als Content ankommen → leere Mail.
$htmlBody = (& $python $pyScript $yesterday 2>&1) -join "`n"
if ([string]::IsNullOrWhiteSpace($htmlBody) -or $htmlBody -match "Traceback|Error:") {
$htmlBody = "<p>Fehler beim Generieren des Reports für $yesterday.</p><pre>$htmlBody</pre>"
}
$subject = "Oil Trading $((Get-Date).AddDays(-1).ToString('dd.MM.yyyy'))"
# ── Microsoft Graph verbinden ────────────────────────────────────────────────
$secureSecret = ConvertTo-SecureString $clientSecret -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential($clientId, $secureSecret)
Connect-MgGraph -TenantId $tenantId -ClientSecretCredential $cred -NoWelcome
# ── E-Mail senden ────────────────────────────────────────────────────────────
$params = @{
Message = @{
Subject = $subject
Body = @{
ContentType = "HTML"
Content = $htmlBody
}
ToRecipients = @(
@{ EmailAddress = @{ Address = "axel@hocks.eu" } }
)
From = @{
EmailAddress = @{ Address = "mailagent@hocks.eu" }
}
}
}
Send-MgUserMail -UserId "mailagent@hocks.eu" -BodyParameter $params
Write-Output "Report gesendet: $subject"
+394
View File
@@ -0,0 +1,394 @@
#!/usr/bin/env python3
"""
server.py — Web-Backend für den Oil Trading Agent
===================================================
FastAPI-Server, der die headless `TradingEngine` (core/engine.py) fährt und
ihren Zustand per REST bereitstellt. Grundlage für die Mobile-Web-Oberfläche.
Läuft auf demselben Windows-Rechner wie das MT5-Terminal. Das Tkinter-Widget
ist NICHT nötig — Server und Widget teilen sich nur die core/-Logik. Betreibe
entweder das Widget ODER den Server gegen dasselbe MT5-Terminal.
Start:
pip install fastapi "uvicorn[standard]"
python server.py
→ http://localhost:8000/api/snapshot
Aktuell (Schritt 1): nur Read-only-Endpoints.
GET / → Status-Kurzinfo
GET /api/health → läuft die Engine, ist MT5 verbunden?
GET /api/snapshot → kompletter Live-Zustand (Markt/Position/Analyse/Agent)
"""
from __future__ import annotations
import asyncio
import contextlib
import secrets
import sys
from pathlib import Path
try:
import uvicorn
from fastapi import (FastAPI, WebSocket, WebSocketDisconnect,
Header, HTTPException, Body)
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
except ImportError:
print('pip install fastapi "uvicorn[standard]"')
sys.exit(1)
from core.config import save_config
from core.engine import TradingEngine
from core.logger import get_logger
log = get_logger("server")
WEB_DIR = Path(__file__).parent / "web"
WS_PUSH_S = 1.0 # Push-Intervall des Live-Snapshots
# Im Heim-VPN (WireGuard) erreichen Handy/Frontend den Server direkt; CORS
# offen lassen ist hier vertretbar. Bei öffentlichem Betrieb einschränken.
HOST = "0.0.0.0"
PORT = 8000
def _ensure_token(engine: TradingEngine) -> str:
"""App-Token für Trade-Aktionen sicherstellen — bei Bedarf generieren."""
cfg = engine.cfg
if "web" not in cfg:
cfg["web"] = {}
token = cfg["web"].get("api_token", "").strip()
if not token:
token = secrets.token_urlsafe(16)
cfg["web"]["api_token"] = token
try:
save_config(cfg)
except Exception as e:
log.warning(f"Token speichern: {e}")
log.warning("=" * 54)
log.warning(f" APP-TOKEN (für Trade-Steuerung am Handy):\n {token}")
log.warning(" Einmalig im Handy-UI eingeben. Liegt in [web] api_token.")
log.warning("=" * 54)
return token
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
engine = TradingEngine()
app.state.token = _ensure_token(engine)
if not engine.start():
log.error(f"Engine-Start fehlgeschlagen: {engine._error}")
# Server läuft trotzdem weiter, damit /api/health den Fehler zeigt.
app.state.engine = engine
try:
yield
finally:
engine.stop()
log.info("Engine gestoppt")
def _require_auth(x_auth_token: str | None):
"""Prüft den App-Token (konstante-Zeit-Vergleich).
Ist die Token-Pflicht abgeschaltet ([web] require_token=false), entfällt
die Prüfung — der Zugang ist dann anderweitig abzusichern (z.B. WireGuard)."""
if not getattr(app.state.engine, "require_token", True):
return
expected = app.state.token
if not x_auth_token or not secrets.compare_digest(x_auth_token, expected):
raise HTTPException(status_code=401, detail="Ungültiger Token")
app = FastAPI(title="Oil Trading Agent", version="1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
)
@app.middleware("http")
async def _no_cache(request, call_next):
"""UI-Dateien NIE cachen (no-store) — sonst läuft am Handy alte JS/HTML
weiter. no-store statt no-cache, damit der Browser jedesmal frisch lädt."""
resp = await call_next(request)
path = request.url.path
if path == "/" or path.endswith((".js", ".css", ".html", ".json")):
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
resp.headers["Expires"] = "0"
return resp
@app.get("/api/health")
def health():
eng = app.state.engine
return {"running": eng._running,
"connected": eng._connected,
"error": eng._error,
"symbol": eng.data.symbol,
"wave_tf": eng._wave_tf_label}
@app.get("/api/snapshot")
def snapshot():
eng = app.state.engine
return JSONResponse(content=_json_safe(eng.snapshot_cached()))
@app.get("/api/logs")
def logs(lines: int = 200):
"""Letzte N Zeilen der Logdatei (nur das Dateiende lesen — effizient)."""
from core.logger import LOG_FILE
import os
lines = max(1, min(lines, 1000))
try:
size = os.path.getsize(LOG_FILE)
with open(LOG_FILE, "rb") as f:
f.seek(max(0, size - 256 * 1024)) # nur die letzten 256 KB
data = f.read().decode("utf-8", "ignore")
tail = data.splitlines()[-lines:]
return {"lines": tail}
except Exception as e:
return {"error": str(e), "lines": []}
def _add_net(stats: dict, rate: float) -> dict:
"""Ergänzt eine stats_overview-Periode um Netto-Werte nach Quellensteuer.
WHT wird nur auf die Gewinnseite (gross_win) erhoben, Verluste bleiben voll."""
if not stats or stats.get("error"):
return stats
gw = stats.get("gross_win", 0.0) or 0.0
gl = stats.get("gross_loss", 0.0) or 0.0
wht = gw * rate
net_gw = gw - wht
stats["wht_pct"] = round(rate * 100, 3)
stats["wht"] = wht
stats["net_pnl"] = (stats.get("total_pnl", 0.0) or 0.0) - wht
stats["net_profit_factor"] = (net_gw / gl) if gl > 0 else None
stats["net_avg_win"] = (stats.get("avg_win", 0.0) or 0.0) * (1 - rate)
return stats
def _add_net_setup(s: dict, rate: float) -> dict:
"""Netto-Werte nach WHT für eine Setup-Zeile (nur Gewinnseite besteuert)."""
gw = s.get("gross_win", 0.0) or 0.0
gl = s.get("gross_loss", 0.0) or 0.0
s["net_pnl"] = (s.get("total_pnl", 0.0) or 0.0) - gw * rate
s["net_profit_factor"] = (gw * (1 - rate) / gl) if gl > 0 else None
return s
@app.get("/api/stats")
def stats():
eng = app.state.engine
try:
rate = float(eng.cfg["trading"].get("wht_pct", "0")) / 100.0
except Exception:
rate = 0.0
out: dict = {"wht_pct": round(rate * 100, 3)}
for period in ("today", "week", "all"):
try:
out[period] = _add_net(eng.history.stats_overview(period), rate)
except Exception as e:
out[period] = {"error": str(e)}
try:
out["setups"] = [_add_net_setup(s, rate)
for s in eng.history.setup_stats("all")]
except Exception as e:
out["setups"] = [{"error": str(e)}]
try:
out["n_total"] = eng.history.n_total_trades()
except Exception:
out["n_total"] = None
return JSONResponse(content=_json_safe(out))
@app.get("/api/news")
def news():
eng = app.state.engine
headlines, last_fetch, error = eng.news.snapshot()
return JSONResponse(content=_json_safe({
"headlines": headlines[:30],
"last_fetch": last_fetch,
"error": error,
"sentiment": eng.news.sentiment_snapshot(),
}))
@app.get("/api/bars")
def bars(tf: str = "M5", n: int = 90):
"""OHLC-Bars + EMA12/50 + Trend je Timeframe (Charts-Tab). Read-only, kein Token."""
return JSONResponse(content=_json_safe(app.state.engine.get_bars(tf, n)))
@app.get("/api/squeeze_monitor")
def squeeze_monitor():
"""Auto-Squeeze-B4-Monitor: Live-Trades (echt) + Mechanik (Entry-Edge isoliert)
gegen die Backtest-Erwartung. Read-only, kein Token. Cache-TTL 60 s im Engine."""
return JSONResponse(content=_json_safe(app.state.engine.squeeze_monitor()))
@app.get("/api/pbreak_accuracy")
def pbreak_accuracy(period: str = "all"):
"""Trefferquote der Abprall/Durchbruch-Prognose (P(break)-Modell), ausgewertet
gegen candles_m1. period: today|week|all. Read-only, kein Token."""
return JSONResponse(content=_json_safe(app.state.engine.history.pbreak_accuracy(period)))
@app.websocket("/ws")
async def ws_snapshot(websocket: WebSocket):
"""Pusht den Live-Snapshot im Sekundentakt. Mehrere Geräte parallel ok."""
await websocket.accept()
eng = app.state.engine
loop = asyncio.get_event_loop()
try:
while True:
# snapshot() ist blockierend (Locks + sqlite) → im Threadpool holen,
# damit der Event-Loop frei bleibt. Cache teilt EINE Berechnung über
# alle WS-Clients (TTL) → DB-Last unabhängig von der Geräteanzahl.
snap = await loop.run_in_executor(None, eng.snapshot_cached)
await websocket.send_json(_json_safe(snap))
await asyncio.sleep(WS_PUSH_S)
except WebSocketDisconnect:
pass
except Exception as e:
log.warning(f"WebSocket beendet: {e}")
# ── Trade-Steuerung (token-geschützt) ─────────────────────────────────────────
@app.post("/api/auth/check")
async def auth_check(x_auth_token: str | None = Header(default=None)):
"""Frontend prüft hiermit, ob der eingegebene Token gültig ist."""
_require_auth(x_auth_token)
return {"ok": True}
@app.post("/api/order")
async def order(payload: dict = Body(...),
x_auth_token: str | None = Header(default=None)):
_require_auth(x_auth_token)
side = (payload.get("side") or "").lower()
eng = app.state.engine
loop = asyncio.get_event_loop()
if side == "long":
fn = eng.open_long
elif side == "short":
fn = eng.open_short
elif side == "close":
fn = eng.close
else:
raise HTTPException(status_code=400, detail="side muss long|short|close sein")
log.info(f"[WEB] Order angefordert: {side}")
err = await loop.run_in_executor(None, fn) # blockierender MT5-Call
if err:
log.warning(f"[WEB] Order {side} fehlgeschlagen: {err}")
return JSONResponse(status_code=200, content={"ok": False, "error": str(err)})
return {"ok": True, "side": side}
@app.post("/api/emergency")
async def emergency(payload: dict = Body(...),
x_auth_token: str | None = Header(default=None)):
"""Notfall-Verlust-Schwelle setzen/löschen. `value`: Betrag (Kontowährung)
oder null/0 = aus. Der Server schließt die Position, sobald P&L ≤ value."""
_require_auth(x_auth_token)
cur = app.state.engine.set_emergency(payload.get("value"))
log.info(f"[WEB] Notfall-Stop gesetzt: {cur}")
return {"ok": True, "emergency_loss": cur}
@app.post("/api/takeprofit")
async def takeprofit(payload: dict = Body(...),
x_auth_token: str | None = Header(default=None)):
"""Gewinn-Ziel setzen/löschen. `value`: Betrag (Kontowährung) oder null/0 = aus.
Der Server schließt die Position, sobald P&L ≥ +value."""
_require_auth(x_auth_token)
cur = app.state.engine.set_takeprofit(payload.get("value"))
log.info(f"[WEB] Gewinn-Ziel gesetzt: {cur}")
return {"ok": True, "takeprofit": cur}
@app.post("/api/sltp")
async def set_sltp(payload: dict = Body(...),
x_auth_token: str | None = Header(default=None)):
"""Manuelles SL/TP der offenen Position setzen (deaktiviert das Trailing).
`sl`/`tp`: Preis oder null (= unverändert). Broker-Prüfung → error bei Ablehnung."""
_require_auth(x_auth_token)
res = await asyncio.get_event_loop().run_in_executor(
None, lambda: app.state.engine.set_sltp(payload.get("sl"), payload.get("tp")))
log.info(f"[WEB] SL/TP: {res}")
return res
@app.post("/api/srclose")
async def toggle_srclose(x_auth_token: str | None = Header(default=None)):
"""Automatischen S/R-Close (P(break)-Regel) an/aus."""
_require_auth(x_auth_token)
eng = app.state.engine
state = eng.set_sr_autoclose(not eng._auto_sr_close)
return {"ok": True, "enabled": state}
@app.post("/api/autosqueeze")
async def toggle_autosqueeze(x_auth_token: str | None = Header(default=None)):
"""Autonomen Squeeze-Entry (echte Order auf Ausbruch) an/aus."""
_require_auth(x_auth_token)
eng = app.state.engine
state = eng.set_auto_squeeze(not eng._auto_squeeze)
return {"ok": True, "enabled": state}
@app.post("/api/srclose_min")
async def srclose_min(payload: dict = Body(...),
x_auth_token: str | None = Header(default=None)):
"""Mindestgewinn (EUR) für den S/R-Auto-Close setzen. 0/leer = aus."""
_require_auth(x_auth_token)
get_logger("server").info(f"[WEB] Mindestgewinn angefordert: {payload.get('value')!r}")
cur = app.state.engine.set_sr_close_min_gain(payload.get("value"))
return {"ok": True, "min_gain": cur}
@app.post("/api/trail")
async def toggle_trail(x_auth_token: str | None = Header(default=None)):
_require_auth(x_auth_token)
state = await asyncio.get_event_loop().run_in_executor(
None, app.state.engine.toggle_trail)
return {"ok": True, "enabled": state}
def _json_safe(obj):
"""Macht den Snapshot robust JSON-fähig (numpy-Floats, datetime, Tupel)."""
import datetime as _dt
if isinstance(obj, dict):
return {str(k): _json_safe(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_json_safe(v) for v in obj]
if isinstance(obj, _dt.datetime):
return obj.isoformat()
# numpy-Skalare → Python-Zahl
if hasattr(obj, "item") and not isinstance(obj, (str, bytes)):
try:
return obj.item()
except Exception:
return str(obj)
return obj
# Frontend zuletzt mounten, damit /api/* und /ws Vorrang haben.
# html=True liefert web/index.html unter "/" aus.
if WEB_DIR.is_dir():
app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web")
else:
log.warning(f"Web-Verzeichnis fehlt: {WEB_DIR}")
if __name__ == "__main__":
print("=" * 58)
print(" Oil Trading Agent — Web-Backend (FastAPI)")
print(f" Mobile-UI: http://localhost:{PORT}/")
print(f" JSON-API: http://localhost:{PORT}/api/snapshot")
print("=" * 58)
# access_log=False: Uvicorns Zugriffs-Log (eine Zeile je HTTP-Request) schweigt
# jetzt — seit dem 4-s-REST-Polling (2026-07-22) + WS-Push flutete das die Konsole
# (GET /api/snapshot alle paar Sekunden). Betrifft NUR das Access-Log, nicht die
# eigenen oil.*-Logger (core/logger.py, eigene Hierarchie) oder Uvicorn-Fehler.
uvicorn.run(app, host=HOST, port=PORT, log_level="info", access_log=False)
+13
View File
@@ -0,0 +1,13 @@
@echo off
REM ============================================================
REM Oil Trading Agent - Web-Backend starten
REM Doppelklick startet den FastAPI-Server (server.py).
REM Fenster offen lassen = Server laeuft. Strg+C = stoppen.
REM ============================================================
title Oil Trading Server
cd /d "%~dp0"
set PYTHONIOENCODING=utf-8
"C:\Users\ah\AppData\Local\Programs\Python\Python312\python.exe" -X utf8 server.py
echo.
echo ===== Server beendet =====
pause
+12
View File
@@ -0,0 +1,12 @@
' ============================================================
' Startet start_server.bat OHNE sichtbares Fenster.
' Wird von der Aufgabenplanung beim Login aufgerufen
' (Auto-Start). Manuell zum Testen ebenfalls per Doppelklick.
' ============================================================
Dim sh, fso, here
Set sh = CreateObject("WScript.Shell")
Set fso = CreateObject("Scripting.FileSystemObject")
here = fso.GetParentFolderName(WScript.ScriptFullName)
sh.CurrentDirectory = here
' Fensterstil 0 = unsichtbar, False = nicht auf Ende warten
sh.Run """" & here & "\start_server.bat""", 0, False
+11
View File
@@ -0,0 +1,11 @@
@echo off
REM ============================================================
REM Oil Trading Agent - Web-Backend stoppen
REM Beendet den python-Prozess, der server.py ausfuehrt.
REM Nutzt absolute Pfade (PATH-unabhaengig).
REM ============================================================
title Oil Trading Server - Stop
set "PS=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
"%PS%" -NoProfile -Command "$p = Get-CimInstance Win32_Process | Where-Object { $_.Name -eq 'python.exe' -and $_.CommandLine -like '*server.py*' }; if ($p) { $p | ForEach-Object { Stop-Process -Id $_.ProcessId -Force; Write-Host ('Gestoppt: PID ' + $_.ProcessId) } } else { Write-Host 'Kein laufender Server gefunden.' }"
echo.
pause
+1167
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
<rect width="192" height="192" rx="36" fill="#0d1117"/>
<circle cx="96" cy="96" r="58" fill="none" stroke="#e8a000" stroke-width="10"/>
<path d="M96 52 v44 l30 18" fill="none" stroke="#3fb950" stroke-width="10"
stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="96" cy="96" r="8" fill="#e8a000"/>
</svg>

After

Width:  |  Height:  |  Size: 393 B

+221
View File
@@ -0,0 +1,221 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0d1117">
<title>Oil · MT5</title>
<link rel="manifest" href="manifest.json">
<link rel="stylesheet" href="style.css?v=108">
</head>
<body>
<!-- Modul-Menü (ganz oben) -->
<nav id="tabs">
<button class="tab active" data-view="dash">Dashboard</button>
<button class="tab" data-view="logs">Logs</button>
<button class="tab" data-view="stats">Statistik</button>
<button class="tab" data-view="charts">Charts</button>
<button class="tab" data-view="news">News</button>
<button class="tab mute-tab" id="mute-btn" title="Töne an/aus">🔊</button>
</nav>
<header id="top">
<div class="sym-row">
<span id="symbol"></span>
<div class="hdr-right">
<div class="hdr-meta">
<span id="hdr-clock" class="hdr-clock"></span>
<span id="session" class="session-line dim"></span>
</div>
<span id="conn" class="dot off" title="Verbindung"></span>
</div>
</div>
<div class="price-row">
<div class="px"><label>BALANCE</label><span id="balance"></span></div>
<div class="px"><label>TAG P/L</label><span id="day-pl"></span></div>
<div class="px"><label>EQUITY</label><span id="equity"></span></div>
<div class="px"><label>KURS</label><span id="hdr-price"></span></div>
<div class="px"><label>G/V</label><span id="hdr-pnl"></span></div>
</div>
</header>
<main id="view-dash" class="view">
<!-- Aktueller Trade (nur bei offener Position) — Anzeige + SL/TP/Close editierbar -->
<section class="card wide trade-bar hidden" id="trade-bar">
<h2 class="tb-title">aktueller Trade läuft</h2>
<div class="tb-row">
<span class="tb-field"><label>Richtung</label>
<b class="tb-val tb-dir" id="tb-dir"></b></span>
<span class="tb-field"><label>Lots</label>
<b class="tb-val" id="tb-lots"></b></span>
<span class="tb-field"><label>Einsatz</label>
<b class="tb-val" id="tb-margin"></b></span>
<span class="tb-field"><label>Auto-Close</label>
<button id="srclose-btn" class="tb-val tb-btn"></button></span>
<span class="tb-field stop"><label>SL</label>
<input id="tb-sl" type="number" inputmode="decimal" step="0.01" placeholder="—"></span>
<span class="tb-field gain"><label>TP</label>
<input id="tb-tp" type="number" inputmode="decimal" step="0.01" placeholder="—"></span>
<span class="tb-field gain"><label>Gewinn-Close</label>
<input id="srmin-input" type="number" inputmode="decimal" step="1" placeholder="€"></span>
<span class="tb-field stop"><label>Notfall-Close</label>
<input id="emg-input" type="number" inputmode="decimal" step="1" placeholder="€"></span>
</div>
</section>
<!-- Gesamtempfehlung (aus allen Modulen aggregiert) -->
<section class="card wide" id="card-verdict">
<h2>Gesamtempfehlung</h2>
<div class="vd-head">
<div class="vd-ring" id="vd-ring"><span id="vd-conf"></span></div>
<div class="vd-main">
<div class="vd-signal" id="vd-signal">◌ —</div>
<div class="vd-consensus" id="vd-consensus"></div>
</div>
</div>
<div class="vd-meter">
<span class="vd-end short">SHORT</span>
<div class="vd-track"><div class="vd-needle" id="vd-needle"></div></div>
<span class="vd-end long">LONG</span>
</div>
<div class="vd-modules" id="vd-modules"></div>
<!-- Zeitebenen-Ampel (M1/M5/M15/M30/H1), aus der Welle hierher verschoben -->
<div class="tf-turns" id="wave-turns"></div>
</section>
<!-- Meldungen (ex-Position + Wellen-Meldungen; Welle-Karte entfällt) -->
<section class="card wide" id="card-pos">
<h2>Meldungen</h2>
<div class="squeeze-ind" id="wave-squeeze"></div>
<div class="bounce-ind" id="wave-bounce"></div>
<div class="cur-near" id="cur-near"></div>
<div class="pos-srhint hidden" id="pos-srhint"></div>
<div class="reasons" id="wave-reasons"></div>
<div class="kv"><span>Setup</span><b id="wave-setup"></b></div>
<div class="kv"><span>Lauf</span><b id="wave-move"></b></div>
</section>
<!-- Marktstruktur (M30) — reine Anzeige, kein Signal -->
<section class="card wide" id="card-structure">
<h2>Marktstruktur <span class="ms-tf">M30</span></h2>
<div class="ms-top">
<span class="ms-trend" id="ms-trend"></span>
<div class="ms-swings" id="ms-swings"></div>
</div>
<div class="ms-bos" id="ms-bos"></div>
<div class="ms-chan" id="ms-chan">
<div class="ms-chan-head"><span id="ms-chan-dir"></span>
<span class="ms-chan-note" id="ms-chan-note"></span></div>
<div class="ms-chan-track"><div class="ms-chan-fill" id="ms-chan-fill"></div>
<div class="ms-chan-dot" id="ms-chan-dot"></div></div>
<div class="ms-chan-scale"><span>Kanal unten</span><span>oben</span></div>
</div>
</section>
<!-- KI-Agent -->
<section class="card wide" id="card-agent">
<h2>KI-Copilot</h2>
<div class="signal" id="agent-bias">◌ —</div>
<div class="agent-head" id="agent-head"></div>
<div class="agent-text" id="agent-reason"></div>
<ul class="risks" id="agent-risks"></ul>
</section>
<!-- Trailing (Dashboard-Kachel ausgeblendet 2026-07-22, User-Vorgabe; Backend/
Logik unverändert aktiv, nur die Anzeige raus) -->
<section class="card wide hidden" id="card-trail">
<h2>Trailing</h2>
<div class="kv"><span>Status</span><b id="trail-state"></b></div>
<div class="kv"><span>Phase</span><b id="trail-phase"></b></div>
<div class="kv"><span>SL</span><b id="trail-sl"></b></div>
<div class="kv"><span>TP</span><b id="trail-tp"></b></div>
<div class="kv"><span>ATR · TF</span><b id="trail-atr"></b></div>
</section>
<!-- Letzte Trades -->
<section class="card wide" id="card-trades">
<h2>Letzte Trades</h2>
<div class="trades" id="trades"></div>
</section>
<!-- Kurslücken (Gaps) — Fill-Magnete, nur Kontext -->
<section class="card wide" id="card-gaps">
<h2>Kurslücken (Gaps 2026)</h2>
<div class="gaps-list" id="gaps-list"><span class="dim">lade …</span></div>
</section>
<!-- Live-Statistik — ganz unten -->
<section class="card wide" id="card-stats-live">
<h2>Statistik (live)</h2>
<div class="stx-row" id="stx-row"><span class="dim">lade …</span></div>
</section>
</main>
<!-- Logs -->
<section id="view-logs" class="view hidden">
<div class="view-head">
<h2>Logs</h2>
<button class="reload" id="logs-reload"></button>
</div>
<pre id="logs-body" class="logbox">lade …</pre>
</section>
<!-- Statistik -->
<section id="view-stats" class="view hidden">
<div class="view-head">
<h2>Statistik</h2>
<button class="reload" id="stats-reload"></button>
</div>
<div id="sqm-box"><div class="dim" style="padding:8px">lade Monitor …</div></div>
<div id="stats-body"></div>
<h2 class="sub-h">Setups (gesamt)</h2>
<div id="stats-setups"></div>
</section>
<!-- Charts: M1/M5/M15/M30/H1 untereinander mit Trend (Lightweight Charts) -->
<section id="view-charts" class="view hidden">
<div class="view-head">
<h2>Charts</h2>
<button class="reload" id="charts-reload"></button>
</div>
<div id="charts-body"></div>
</section>
<!-- News -->
<section id="view-news" class="view hidden">
<div class="view-head">
<h2>News</h2>
<button class="reload" id="news-reload"></button>
</div>
<div id="news-sentiment" class="news-sent"></div>
<div id="news-body"></div>
</section>
<!-- Aktionsleiste (Trade-Steuerung) -->
<nav id="actions">
<button class="btn long" data-side="long">▲ LONG</button>
<button class="btn short" data-side="short">▼ SHORT</button>
<button class="btn close" data-side="close">✕ CLOSE</button>
<button class="btn tog" id="btn-squeeze" title="Autonomer Entry auf Squeeze-Ausbruch">🚀 BRK</button>
<button class="btn tog" id="btn-trail">🎯 TRAIL</button>
</nav>
<!-- Modal (Bestätigung / Token-Eingabe) -->
<div id="modal" class="modal hidden">
<div class="box">
<div class="box-title" id="modal-title"></div>
<div class="box-msg" id="modal-msg"></div>
<input id="modal-input" class="hidden" type="password"
placeholder="App-Token" autocomplete="off" autocapitalize="off">
<div class="box-btns">
<button id="modal-cancel" class="mbtn cancel">Abbrechen</button>
<button id="modal-ok" class="mbtn ok">Bestätigen</button>
</div>
</div>
</div>
<div id="toast" class="toast hidden"></div>
<script src="lightweight-charts.standalone.production.js?v=108"></script>
<script src="app.js?v=108"></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More