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:
@@ -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
@@ -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/
|
||||
@@ -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≈|Entry−InitialSL|/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≈|Entry−InitSL|/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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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']}")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,07–0,12, der Floor bindet also
|
||||
staendig und macht SL/Trail-Distanzen ~1,3–1,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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 (|h1−h2| ≤ tol×ATR),
|
||||
dazwischen ein Tal (Nackenlinie) mind. `depth`×ATR tiefer; Einstieg wenn der Kurs
|
||||
FRISCH unter die Nackenlinie bricht (C[i] < neck ≤ C[i−1]).
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -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:30–16:30 · eia_post Mi 16:30–18:00
|
||||
api_pre Di 21:30–22:30 · api_post Di 22:30–23: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()
|
||||
@@ -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()
|
||||
@@ -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 (HW−0,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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 0–7 vs. 0–7+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 0–7",set(range(0,8))),
|
||||
("Nacht 0–7 + 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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 EMA12−EMA50| > 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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,8–2,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 (Model−Live): {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()
|
||||
@@ -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()
|
||||
@@ -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_run−R_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}{'Ø(run−close)':>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 Ø(run−close)<0 zeigt")
|
||||
print(" (= dort wäre Close besser) UND P(break) dort klar unter der Basisrate liegt.")
|
||||
|
||||
if __name__=="__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 ≥ 1−X → 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()
|
||||
@@ -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()
|
||||
@@ -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 HW−mult×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 entry−mult×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()
|
||||
@@ -0,0 +1 @@
|
||||
"""Core-Module für den Oil Trading Server."""
|
||||
+615
@@ -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 1000–1200
|
||||
# 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
@@ -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,
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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:00–08: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,
|
||||
}
|
||||
@@ -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. 0–100."""
|
||||
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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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],
|
||||
}
|
||||
@@ -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
@@ -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,5–3 %."""
|
||||
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,2–1,5 zu eng war (MAE ~1,7×ATR, 31–37 % 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 = M1–M15
|
||||
# (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,50–0,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 = 0–7,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:30–16: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 0–7 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,32–0,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}")
|
||||
@@ -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
@@ -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.382–0.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
File diff suppressed because it is too large
Load Diff
+123
@@ -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
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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:00–17: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,
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
# Mon–Fr 00:00–22:00, So ab 22:00. Broker-Zeiten können minimal abweichen.
|
||||
_OIL_SESSIONS_UTC = {
|
||||
0: [(79200, 86400)], # So 22:00–24:00
|
||||
1: [(0, 79200)], # Mo 00:00–22:00
|
||||
2: [(0, 79200)], # Di
|
||||
3: [(0, 79200)], # Mi
|
||||
4: [(0, 79200)], # Do
|
||||
5: [(0, 79200)], # Fr 00:00–22: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
@@ -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
@@ -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}"
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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,3–1,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.2–1.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.5–4.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.5–4.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,
|
||||
}
|
||||
@@ -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 (MA5–MA200, 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}
|
||||
@@ -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
|
||||
|EMA12−EMA50| < _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 40–54 % = 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,5–3,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 10–20 / k 0,1–0,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 # |EMA12−EMA50| < 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):
|
||||
# 0–7 Uhr = Nacht-Kostenfalle (konstanter Spread ÷ niedriger Nacht-ATR =
|
||||
# 0,32–0,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 „0–7+12" verbesserte BEIDE Hälften (H1 −4038→−1870, H2 +100→+1191).
|
||||
# Robust positiv sind nur 21–22 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 0–23). 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 2–3× 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 — 79–82 % 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:30–16: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:30–16: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 (0–0,3) ist der schwächste
|
||||
# Edge; weit gelaufenes Trend-Momentum (1–2×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 40–54 %) → 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
|
||||
@@ -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,2–1,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 3–4×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)
|
||||
@@ -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`.
|
||||
@@ -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,8–2,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,8–2,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.
|
||||
@@ -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 von–bis':<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()
|
||||
@@ -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();
|
||||
}
|
||||
//+------------------------------------------------------------------+
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 — {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))
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
Reference in New Issue
Block a user