From 75d28827e88aae789405a6c8470a79d010b5c66b Mon Sep 17 00:00:00 2001 From: Axel Hocks Date: Fri, 24 Jul 2026 08:29:23 +0200 Subject: [PATCH] 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 --- .claude/settings.json | 8 + .gitignore | 44 + CLAUDE.md | 1179 ++++++++++ analyze_execution.py | 161 ++ analyze_pbreak_flicker.py | 107 + analyze_srwaves.py | 134 ++ analyze_today.py | 91 + backtest_adverse_nosignal.py | 137 ++ backtest_angle.py | 99 + backtest_atrfloor.py | 104 + backtest_bounce.py | 84 + backtest_bounce_reliability.py | 76 + backtest_breakout.py | 104 + backtest_breakout_squeeze.py | 110 + backtest_chopgate.py | 94 + backtest_conf.py | 84 + backtest_confluence.py | 169 ++ backtest_confluence_angle.py | 108 + backtest_deadhour.py | 103 + backtest_dist.py | 85 + backtest_doubletop.py | 140 ++ backtest_entryroom.py | 162 ++ backtest_events.py | 96 + backtest_exit.py | 205 ++ backtest_exit2.py | 116 + backtest_flipclose.py | 131 ++ backtest_gaps.py | 105 + backtest_hourly.py | 88 + backtest_hourly_split.py | 113 + backtest_htf_angle.py | 98 + backtest_htf_filter.py | 133 ++ backtest_improve.py | 130 ++ backtest_intermarket.py | 204 ++ backtest_momentum.py | 92 + backtest_orb.py | 104 + backtest_pullback.py | 116 + backtest_range.py | 117 + backtest_realcosts.py | 129 ++ backtest_rev_exit.py | 88 + backtest_rev_htf.py | 91 + backtest_revconf.py | 133 ++ backtest_reversal.py | 71 + backtest_revlock.py | 84 + backtest_short_h1filter.py | 128 ++ backtest_signal.py | 108 + backtest_sizing.py | 168 ++ backtest_sl_method.py | 133 ++ backtest_sl_tf_mismatch.py | 135 ++ backtest_squeeze_exit.py | 140 ++ backtest_srbreak.py | 143 ++ backtest_srclose.py | 127 ++ backtest_srclose_prob.py | 149 ++ backtest_srclose_signal.py | 82 + backtest_stretch.py | 99 + backtest_structure.py | 150 ++ backtest_timestop.py | 102 + backtest_trailing.py | 117 + core/__init__.py | 1 + core/agent.py | 615 +++++ core/ai.py | 343 +++ core/analysis/__init__.py | 51 + core/analysis/ict.py | 348 +++ core/analysis/indicators.py | 252 ++ core/analysis/m15.py | 257 +++ core/analysis/news.py | 76 + core/candle_logger.py | 100 + core/config.py | 418 ++++ core/daily_levels.py | 159 ++ core/elliott.py | 251 ++ core/engine.py | 2018 +++++++++++++++++ core/gaps.py | 123 + core/history.py | 868 +++++++ core/logger.py | 88 + core/mailer.py | 60 + core/market_hours.py | 77 + core/mt5_utils.py | 161 ++ core/mt5data.py | 297 +++ core/news.py | 842 +++++++ core/notify.py | 112 + core/structure.py | 247 ++ core/trader.py | 709 ++++++ core/trailing.py | 717 ++++++ core/tu_rating.py | 201 ++ core/wave_rec.py | 786 +++++++ docs/exit-simulation.md | 46 + docs/gesamtempfehlung.md | 68 + docs/risiko-management.md | 54 + find_gaps.py | 87 + mql5/SR_Levels.mq5 | 249 ++ oil_widget_config.ini.example | 110 + restart_server.bat | 19 + scripts/daily_summary.py | 138 ++ scripts/send_daily_report.ps1 | 63 + server.py | 394 ++++ start_server.bat | 13 + start_server_hidden.vbs | 12 + stop_server.bat | 11 + web/app.js | 1167 ++++++++++ web/icon.svg | 7 + web/index.html | 221 ++ ...ightweight-charts.standalone.production.js | 7 + web/manifest.json | 18 + web/style.css | 418 ++++ weekly_review.py | 172 ++ 104 files changed, 21059 insertions(+) create mode 100644 .claude/settings.json create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 analyze_execution.py create mode 100644 analyze_pbreak_flicker.py create mode 100644 analyze_srwaves.py create mode 100644 analyze_today.py create mode 100644 backtest_adverse_nosignal.py create mode 100644 backtest_angle.py create mode 100644 backtest_atrfloor.py create mode 100644 backtest_bounce.py create mode 100644 backtest_bounce_reliability.py create mode 100644 backtest_breakout.py create mode 100644 backtest_breakout_squeeze.py create mode 100644 backtest_chopgate.py create mode 100644 backtest_conf.py create mode 100644 backtest_confluence.py create mode 100644 backtest_confluence_angle.py create mode 100644 backtest_deadhour.py create mode 100644 backtest_dist.py create mode 100644 backtest_doubletop.py create mode 100644 backtest_entryroom.py create mode 100644 backtest_events.py create mode 100644 backtest_exit.py create mode 100644 backtest_exit2.py create mode 100644 backtest_flipclose.py create mode 100644 backtest_gaps.py create mode 100644 backtest_hourly.py create mode 100644 backtest_hourly_split.py create mode 100644 backtest_htf_angle.py create mode 100644 backtest_htf_filter.py create mode 100644 backtest_improve.py create mode 100644 backtest_intermarket.py create mode 100644 backtest_momentum.py create mode 100644 backtest_orb.py create mode 100644 backtest_pullback.py create mode 100644 backtest_range.py create mode 100644 backtest_realcosts.py create mode 100644 backtest_rev_exit.py create mode 100644 backtest_rev_htf.py create mode 100644 backtest_revconf.py create mode 100644 backtest_reversal.py create mode 100644 backtest_revlock.py create mode 100644 backtest_short_h1filter.py create mode 100644 backtest_signal.py create mode 100644 backtest_sizing.py create mode 100644 backtest_sl_method.py create mode 100644 backtest_sl_tf_mismatch.py create mode 100644 backtest_squeeze_exit.py create mode 100644 backtest_srbreak.py create mode 100644 backtest_srclose.py create mode 100644 backtest_srclose_prob.py create mode 100644 backtest_srclose_signal.py create mode 100644 backtest_stretch.py create mode 100644 backtest_structure.py create mode 100644 backtest_timestop.py create mode 100644 backtest_trailing.py create mode 100644 core/__init__.py create mode 100644 core/agent.py create mode 100644 core/ai.py create mode 100644 core/analysis/__init__.py create mode 100644 core/analysis/ict.py create mode 100644 core/analysis/indicators.py create mode 100644 core/analysis/m15.py create mode 100644 core/analysis/news.py create mode 100644 core/candle_logger.py create mode 100644 core/config.py create mode 100644 core/daily_levels.py create mode 100644 core/elliott.py create mode 100644 core/engine.py create mode 100644 core/gaps.py create mode 100644 core/history.py create mode 100644 core/logger.py create mode 100644 core/mailer.py create mode 100644 core/market_hours.py create mode 100644 core/mt5_utils.py create mode 100644 core/mt5data.py create mode 100644 core/news.py create mode 100644 core/notify.py create mode 100644 core/structure.py create mode 100644 core/trader.py create mode 100644 core/trailing.py create mode 100644 core/tu_rating.py create mode 100644 core/wave_rec.py create mode 100644 docs/exit-simulation.md create mode 100644 docs/gesamtempfehlung.md create mode 100644 docs/risiko-management.md create mode 100644 find_gaps.py create mode 100644 mql5/SR_Levels.mq5 create mode 100644 oil_widget_config.ini.example create mode 100644 restart_server.bat create mode 100644 scripts/daily_summary.py create mode 100644 scripts/send_daily_report.ps1 create mode 100644 server.py create mode 100644 start_server.bat create mode 100644 start_server_hidden.vbs create mode 100644 stop_server.bat create mode 100644 web/app.js create mode 100644 web/icon.svg create mode 100644 web/index.html create mode 100644 web/lightweight-charts.standalone.production.js create mode 100644 web/manifest.json create mode 100644 web/style.css create mode 100644 weekly_review.py diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..77444b5 --- /dev/null +++ b/.claude/settings.json @@ -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\")" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8657799 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0c8fcce --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,1179 @@ +# CLAUDE.md — Oil Trading Bot (MT5, WTI) + +Projektleitfaden für Claude Code. Antworten/UI-Texte auf **Deutsch**. + +> 🔄 **Pflege-Regel:** Diese Datei nach **jeder** inhaltlichen Änderung +> mitaktualisieren (geänderte Logik/Werte, neue Module/Endpoints, UI-/Alarm- +> Verhalten, Config-Schalter, Workflows, Asset-Version `v=N`). Kein separater +> Auftrag nötig — Teil jeder Aufgabe. +> +> 🧱 **5-Layer-Prozess bei jeder Änderung** (Daueregel, in dieser Reihenfolge): +> **L1** Kontext, Befunde **& Risiko/Auswirkung** (offene Befunde hier · „erst +> messen" bei Strategie · was kostet/ändert es — EUR-Risiko/Trade, reversibel?) · +> **L2** Code-Analyse & Design (Datenquelle, *Quelle der Wahrheit*, wo lebt die +> Logik) · **L3** UI-Design (Module klar in Text **und** Grafik, auf dem geklärten +> Datenmodell) · **L4** Implementieren, Verifizieren **& Rollout** (`py_compile` · +> JS-Klammern · Backtest/Test · Version-Bump `v=N` · Aktivierungsweg `restart_server.bat` +> · **Backup vor destruktiven Schritten**, v. a. DB) · **L5** Doku aktualisieren +> (`docs/` **und** CLAUDE.md, zuletzt). +> +> ⚖ **Layer an die Größe der Änderung skalieren** — nicht jede Aufgabe braucht alle +> 5. Strategie-Änderung = alle 5 (inkl. Backtest); reine Anzeige = L2–L5 light (kein +> Backtest); Doku-Korrektur = nur L5. Prozess soll helfen, nicht zur Bürokratie werden. +> +> 🔬 **Track B — Strategie-Lebenszyklus** (ZUSÄTZLICH zu L1–L5, für ALLES, was den +> **Edge** berührt: Signal, Risk/Sizing, Exit, Gate, Filter). Track A = L1–L5 +> (Code/UI/Infra) bleibt unverändert; Track B läuft *parallel* für Strategie-Ideen: +> **B1 Hypothese + Hebel-Check** — welcher Hebel? *Signal = klein* (6× belegt: Filter +> tragen kaum → **höhere Beweislast**, im Zweifel gar nicht bauen) vs. *Sizing/Exit = +> groß* (dort liegt das Geld). · **B2 2-Stichproben-Backtest** — IMMER ≥2 Zeiträume/ +> Regime, nie eine (Dead-Hour-Lektion: Std 13 war in einer Stichprobe „profitabel", +> in der zweiten Rauschen; `backtest_hourly_split.py`). · **B3 Kosten-Check** — Ø-Edge +> **netto > ~0,1×ATR** (real Spread+Komm.)? sonst raus. · **B4 Live-klein + Monitor** — +> ausrollen, dann **Live-WR/PF/Verhältnis** (Statistik-live-Kachel `stats_compact` + +> Flip-Zähler) **gegen die Backtest-Erwartung** halten; Kadenz **wöchentlich** (Termin, +> keine Stufe). · **B5 Behalten oder Verwerfen** — driftet es live weg (Regimewechsel) +> → **zurückbauen, kein Ego.** B4/B5 = die Verfall-Erkennung, die im reinen „Änderung +> machen"-Prozess fehlt (Trading ≠ Software: Edge kann verfallen). +> +> 🧾 **B0 — Handels-Checkliste (Mensch, vor jedem manuellen Trade):** (1) **nicht gegen +> das Signal** (gemessen WR 14 %); (2) **Notfall-Stop gesetzt?**; (3) **Gewinner läuft +> das Trailing** (nicht früh kappen — Ø-Verlust ≫ Ø-Gewinn ist die Kern-Leckage). Steuert +> die eigentliche Verlustquelle (diskretionäre Abweichungen), die kein Code-Layer erreicht. + +## Was das ist +Algorithmischer **WTI-Öl-Trading-Bot** auf MetaTrader 5. (Brent-Umstellung am +2026-07-09 auf User-Wunsch **noch am selben Tag zurückgenommen** — falls je wieder +Brent: Pepperstone-Symbol heißt `SpotBrent`, kein „UKOIL"; breakout_k=0,3 wurde +dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: +- **`server.py` + `core/engine.py`** — headless FastAPI-Web-Backend für die + **Mobile-PWA** in `web/` (Zugang vom Handy via WireGuard, `192.168.188.100:8000`). + +> Das frühere Tkinter-Widget (`oil_trading_agent.py`, `ui/`, `single_instance.py`) +> wurde **entfernt** — nur noch der Server. ⚠ Immer nur **EINE** Server-Instanz +> gegen dasselbe MT5-Terminal laufen lassen (zwei TrailingManager = Konflikt). + +## Architektur (core/) +- `mt5data.py` Preis/Trend/Konto · `trader.py` Orders+SL/TP · `trailing.py` + Trailing/Teil-Exit · `wave_rec.py` **Empfehlung** · `elliott.py` EW/FVG · + `tu_rating.py` TradersUnion · `agent.py` KI-Copilot + (LLM) · `daily_levels.py` Tages-Prognose (Web) · `gaps.py` D1-Kurslücken · + `mailer.py` Graph-E-Mail · + `structure.py` Marktstruktur (M30: HH/HL/LH/LL, BOS, Regressionskanal — reine + Anzeige, kein Signal) · `candle_logger.py` M1-Candle-Log (Daten-Sammlung → `candles_m1`) · + `market_hours.py` Sessions · `history.py` SQLite · `config.py` Konstanten+ + `oil_widget_config.ini`-Loader · `mt5_utils.py` globaler `mt5_lock`. +- `engine.py` = headless Motor: instanziiert die core-Objekte, fährt Loops als + Threads, liefert `snapshot()` für `/api/snapshot` + WebSocket `/ws`. + `_verdict()` aggregiert die Module zur **Gesamtempfehlung** (`verdict` im + Snapshot) — Doku: `docs/gesamtempfehlung.md`. + +## Harte Fakten / Stolpersteine +- **MT5 ist Windows-only** (`MetaTrader5`-Wheel). Backend läuft auf Windows neben + dem Terminal; nur Reverse-Proxy/HTTPS könnte auf Linux/Docker. +- **Brokerzeit = UTC+3.** `deal.time`/`tick.time` immer via `_broker_offset_s()` + bzw. Live-Offset in UTC/Lokalzeit umrechnen. +- **`mt5_lock` (global, nicht reentrant):** alle MT5-Calls darüber, mit Timeout. + Nicht aus einem bereits-gehaltenen Lock erneut `close()` (Deadlock). ⚠ Auch + Thread-Pool-Jobs müssen den Lock nehmen: `M15Analyzer.analyze()` läuft im + Executor → eigener `mt5_lock`-Wrapper in `mt5data._fetch_trend_locked` (sonst + Race). `sr.detect()` läuft inline unter dem bereits gehaltenen Lock. Ebenso: + `mt5data.reconnect()` (shutdown/initialize) und `trader._log_open`-`delayed_log` + (positions_get im Thread) laufen jetzt unter `mt5_lock` (waren Races). +- **Steuer:** Broker behält **WHT 26,375 %** (DE-Abgeltungst.+Soli) je Gewinn ein + (`WHT #…`-Buchungen), erstattet via tägl. „Tax settlement". Stats sind **brutto**; + `[trading] wht_pct` zeigt eine Netto-Schätzung. +- **Python:** `C:\Users\ah\AppData\Local\Programs\Python\Python312\python.exe` + (hat fastapi/uvicorn/MetaTrader5). System32 fehlte mal in der PATH → Skripte + nutzen absolute Pfade. +- **LLM-Provider (Stand aktuell):** **DeepSeek (`[deepseek]`) ist der aktive + Copilot-Provider** (`[agent] provider=deepseek`, 2026-07-20). `api.deepseek.com`, + Modell **`deepseek-v4-flash`** (`/models` listet flash + pro; alte Namen + `deepseek-chat/-reasoner` gibt es NICHT mehr). ⚠ v4-flash ist ein **Reasoning-Modell** + (content nach `reasoning_content` → `max_tokens=4000` in `_call_deepseek`, sonst leer; + temperature 0.3 ok, schnell ~6 s). **⚠ KEINE Web-Suche** → NICHT für `daily_levels` + nutzbar. Fallback-Kette kimi→zai→local greift, wenn der Key ausfällt. + **Kimi/Moonshot (`[kimi]`, `kimi-k2.6`)** bleibt als Provider verfügbar. Key nur auf dem + **internationalen Endpoint `api.moonshot.ai`** gültig (`.cn` = „Invalid + Authentication"). Modell **`kimi-k2.6`** (`/v1/models` listet auch k2.7-code/k3; + k3 = thinking-only). ⚠ **Zwei Fallstricke:** (1) `temperature` MUSS **1** sein + (andere Werte → HTTP 400); (2) k2.6 ist ein **Reasoning-Modell** → schreibt + VARIABLE ~1000–1200 `reasoning_tokens` (in `message.reasoning_content`) VOR dem + `content` → `max_tokens` großzügig (**4000**, `_call_kimi`), sonst frisst das + Reasoning das Budget und `content` bleibt leer (2000 war teils leer). Moonshot + cached den System-Prompt (`cached_tokens`) → günstiger bei Wiederholung. Kimi ist + CN-Modell → CJK-Drift möglich, vom `_has_cjk`-Retry abgedeckt. + Frühere Option **z.ai/GLM** (`[zai]`, `glm-4.5-flash`, kostenlos) bleibt + reaktivierbar; ⚠ GLM ist Thinking-Modell → IMMER `"thinking":{"type":"disabled"}`. + ⚠ **`_active_provider`-Fix 2026-07-20:** zai/kimi waren vorher NICHT in der + `avail`/`orders`-Map → `provider=zai` fiel still auf `local` (qwen) zurück (die + `[local]`-Logzeilen!). Jetzt beide routbar. OpenAI/Gemini-Keys = kein Guthaben (429). + - **KI-Copilot (`agent.py`):** `[agent] provider=deepseek`. CN-/lokale Modelle + (DeepSeek, Kimi, GLM, qwen) driften ins Chinesische → `_has_cjk`-Erkennung + 1× + Retry deckt `local`, `zai`, `kimi` UND `deepseek` ab. (alle bleiben als Provider- + Optionen wählbar.) + - **`daily_levels.py` bleibt auf z.ai (2026-07-20, gemessen):** Die Tagesprognose + BRAUCHT Live-Web-Suche. DeepSeek hat keine; Kimi-Web-Suche (`$web_search`-builtin) + läuft nur über einen Tool-Loop, ist >2 min langsam und lief im Test in `finish=length` + (Web-Ergebnisse+Reasoning sprengen selbst 4000 Tokens → leerer Content). z.ai macht + es in EINEM sauberen `web_search`-Call → kein Wechsel (Kontext-Feature, kein Edge). + ⚠ **S/R-Kontext-Fix 2026-07-15:** `_tool_zones` füttert dem LLM jetzt die **echten + M5-Pivots** (`wave.pb_levels`, geclustert, nächste 3 je Seite) statt der stale + `[zones]`-Config — plus Prompt-Leitplanke „nutze NUR die übergebenen Level, erfinde + keine runden Marken". Reiner Kontext-Fix (keine Strategie): macht die Kommentar-Level + aktuell/konsistent zum Chart, macht die LLM-Einschätzung aber NICHT validierter + (Copilot bleibt unbelegte Verdict-Stimme, Gewicht nur bei Bias). + - **Tages-Prognose (`daily_levels.py`):** z.ai + Web-Suche, 1×/Tag 07:30 Berlin. + +## Workflows / Befehle +- **Änderungen aktivieren:** `restart_server.bat` (lädt core neu). Auto-Start: + Aufgabe `OilTradingServer` (Login, versteckt via VBS). +- **Web-Assets:** Browser cacht hart → in `web/index.html` `?v=N` **hochzählen** + bei jeder JS/CSS-Änderung; Server sendet `Cache-Control: no-store`. Hängt der + Handy-Cache: Inkognito oder Seite mit frischem `?xN` öffnen. +- **Empfehlungs-Edge messen:** `python backtest_signal.py [TF] [N] [K]` + (ereignisbasiert, nutzt die echte `_build`-Logik). Ø-Edge>0 = trägt. +- **Ausführungs-Kalibrierung:** `python analyze_execution.py` — (A) echtes + Spread-Profil je Berlin-Stunde aus dem Bar-`spread`-Feld, (B) Slippage bei + SL-Closes, (C) MAE/MFE der Live-Trades. **Kern-Befund 2026-07-13:** Spread ist + absolut ~konstant (0,0225), aber relativ **Ø 0,265×ATR** — die alte + Kostenannahme 0,1×ATR war v. a. in Niedrig-Vola-Phasen ~2,5× zu optimistisch + (erklärt Live-PF < Backtest-Erwartung). Stunden 15–17 am günstigsten (~0,13), + **Nacht 0–7 = Kostenfalle (0,32–0,50×ATR)**. Slippage-Tail real: die −35/−54- + Desaster hatten 0,75 Slippage ÜBER den Initial-SL hinaus (harter SL deckelt in + schnellen Märkten nicht). MAE/MFE live: Exits validiert (nur 7 % der Verlierer + waren je ≥1,3×ATR im Plus → Breakeven-Schwelle passt; Gewinner-MAE Median 0,28). + ⚠ Künftige B3-Checks mit **stunden-echten Kosten** rechnen, nicht pauschal 0,1. + (WR/PF/Verhältnis/Worst je KW, Ausrichtungs-Split mit/gegen/ohne) gegen die + Backtest-Erwartung (WR~68 % · PF~1,3 · Verh~0,55) und flaggt Leckagen (Gegen-Signal- + Anteil, WR-Verfall, PF<1, Einzelverlust). **Wöchentlich** laufen lassen (B4/B5). +- **Vor „fertig":** immer `python -m py_compile `; JS grob via Klammern- + Balance prüfen (kein node im Env). +- **Konsole:** UTF-8 erzwingen (`PYTHONIOENCODING=utf-8`, `-X utf8`). +- **Uvicorn-Access-Log AUS** (`access_log=False` in `uvicorn.run`, 2026-07-22): seit + dem 4-s-REST-Polling + WS-Push flutete `GET /api/snapshot … 200 OK` die Konsole. + Betrifft NUR Uvicorns eigenes Zugriffs-Log, nicht die `oil.*`-Logger (eigene + Hierarchie, `core/logger.py`, unverändert in Konsole + Datei). + +## Empfehlungs- & Trade-Logik (Stand aktuell) +- **Richtung = EMA12 vs EMA50** (`wave_rec.py`), NICHT mehr ZigZag-Momentum + (das hatte ~0 Edge). Totband (Chop→WARTEN), Anti-Überdehnung + (`_STRETCH_MAX=3,5×ATR`), +S/R-Konfidenz, +Session-Konfidenz. (TU **entfernt** — + s. u.) +- **Volatilitäts-Squeeze-Breakout = ADDITIVER SETUP, UMGESETZT (User-Idee + „Breakouterkennung" 2026-07-14, gemessen `backtest_breakout_squeeze.py`):** + struktureller Ausbruch aus einer **Kompression** (Box = Spanne der letzten `_SQ_N`=10 + abgeschlossenen M5-Bars ≤ `_SQ_MULT`=2,5×ATR) um `_SQ_K`=0,1×ATR — NICHT der + EMA-Trend-Breakout. **2-Stichproben-positiv** (ØR **+0,227/+0,141** netto Echtkosten, + PF 1,35/1,20, 173/413 Trades; schlägt den Ausbruch aus *beliebiger* Box −0,045/+0,161 + in BEIDEN Hälften) & **robust über Box 10–20 / k 0,1–0,2** (alle 8 Kombis positiv, + kein Parameter-Zufall). Andere Signalklasse als die Welle → komplementär. **Umsetzung:** + `wave._squeeze_one(highs,lows,closes,atr)` (M5). ⚡ **Erkennungs-Latenz ~5 s (Variante A, + 2026-07-21):** der Squeeze läuft auf einem EIGENEN, schnellen 60-Bar-M5-Fetch + (`_sq_due`/`_SQ_REFRESH_S=5.0`, entkoppelt von der 15-s-TF-Ampel-Drossel) → Ausbruch wird + ~15 s→5 s schneller erkannt, dann ~1 s Handeln (`_pos_loop`). Gleiche validierte M5-Regel, + reine Infra (kein Backtest nötig). **Variante B (Squeeze auf M1) = OFFEN/UNBELEGT** — erst + backtesten, wenn der M1-Logger (`candles_m1`, seit 2026-07-21) ab ~2027-01 genug History hat. + Snapshot **`wave.squeeze {state, dir, level, box_atr}`**: `active` = Ausbruch (echtes + Signal LONG/SHORT) · `armed` = komprimiert, Ausbruch steht bevor · None = keine + Kompression (transient — der Ausbruch weitet die Box, Signal klärt sich selbst). + **Verdict-Modul „Squeeze"** (`_verdict`, Gewicht 2,0, nur bei `active`; unter der + Welle=3,0). **UI Welle-Kachel** (`#wave-squeeze`): grün/rot bei `active` + („🚀 Squeeze-Breakout ▲ LONG @ …"), blau bei `armed` („🎯 Kompression … Ausbruch + steht bevor"), grau sonst. ⚠ **Nur der ENTRY** — der User-Teil „Gegen-Trade schließen + + Stop-&-Reverse" ist NICHT gebaut (nicht validiert, verdoppelt Kosten, Chop-Risiko). + Läuft normal über SL/Trailing/Notfall-Stop. Caveats: modeste Stichprobe (Box>12 = + 12–22 Trades = Rauschen), Frequenz ~2–3/Woche → **B4-Wochenmonitor** (Live-ØR/PF + gegen +0,14…+0,23), Runner-abhängig (43 % Treffer). Verwerfen, wenn Live wegdriftet (B5). +- **Momentum-Continuation als Auto-Setup = VERWORFEN (gemessen `backtest_momentum.py`, + 2026-07-17):** Kandidat für Autonomie (Einstieg in Richtung eines frischen + Momentum-Schubs, mom_N kreuzt ±X×ATR; seq. Sim, Live-Exit, Echtkosten). **Fällt + durch den 2-Stichproben-Test:** fast alle N/X in H1 negativ (−0,03…−0,22), in H2 + positiv (+0,02…+0,07) → regime-abhängig. Einzige „beide positiv"-Kombi (N=24/X=2,0) + ist H1 **+0,002 / PF 1,00** = Breakeven-Rauschen, nicht der Squeeze-Maßstab + (+0,14…+0,23). Grund: Momentum-Continuation IST Trendfolge — erbt die Regime- + Anfälligkeit des Wave-Signals, kein distinkter Edge wie die Kompression→Ausbruch- + Klasse. **Autonomie-Fazit: der Squeeze bleibt der EINZIGE validierte Auto-Entry.** + (P(break)-Level-Bounce als Entry ist separat schon verworfen, s. „Range-Einstieg".) +- **Opening Range Breakout (ORB) als Auto-Setup = VERWORFEN (gemessen + `backtest_orb.py`, 2026-07-17, Recherche-Kandidat):** Box = erste 15/30 min nach + EU-Open (10:00 Brk) bzw. US-Open (16:30 Brk), Ausbruch ±0,1×ATR, one-shot, Live- + Exit, Echtkosten. **3 von 4 Varianten kippen zwischen den Hälften** (US-Open H1 + −0,19…−0,27!); einzig EU-15min ist beidseitig positiv, aber nur ØR +0,069/+0,027 + (PF 1,11/1,04) = Rauschen unter dem Kosten-Puffer, und die Nachbar-Parameter + kippen (keine Robustheit — beim Squeeze waren ALLE 8 Kombis positiv). ⚠ Lehrstück: + erster Lauf zeigte EU-Open ØR +1,3/PF 10 — war ein **Selektions-Artefakt** (ATR- + Skip statt Floor ließ in der Niedrig-Vola-H1 nur 9 Morgen durch); mit Floor + verschwand der „Edge". Zeit-Anker ersetzt keinen Struktur-Anker. +- **ATR-Breakout-Bestätigung** (`wave._confirm_breakout`, live `[trading] + breakout_k=0.3` — Split-validiert, s. u.; 0=aus): ein + Richtungssignal wird **erst durchgelassen, wenn der Kurs k×ATR in + Signalrichtung** gelaufen ist („X dynamisch"); läuft er vorher k×ATR DAGEGEN/ + Timeout → neu verankern, solange **WARTEN** (Grund „warte auf Breakout …", + `snap.breakout`). **Stateful** (`_pend`), reset bei TF-Wechsel. Gemessen + (`backtest_breakout.py`): hebt Ø-Edge/Trade **~2×** (+0,185→+0,36 bei k=1,0), + Treffer 43→47 %, ~halb so viele Trades → **netto** klar besser (halbe Spread-/ + Komm.-Kosten; lohnt ab Kosten >0,037×ATR, real ~0,1×ATR). = die „Zwei-Konten- + Hedge"-Idee des Users, auf EINEM Konto ohne doppelte Kosten. **k=0,3 seit + 2026-07-08 (Split-validiert, 80k Bars in 2 Halbjahren):** k=0,3 ist in BEIDEN + Hälften das Beste — H1: ØR +0,119/netto +339 (0,5: +0,055/**−831**; 1,0: + +0,046/−805 — beide netto negativ!), H2: +0,362/+4239 (0,5: +3308; 1,0: +3838). + Grund: 0,3 filtert Fehlstarts, behält aber den besseren Einstiegspreis (höheres k + = späterer Einstieg, Anfang des Moves verschenkt). ⚠ Ein-Stichproben-Lauf hatte + „1,0 besser" gezeigt — Split-Prüfung drehte das (B2!). Nebeneffekt: kürzere + „warte auf Breakout"-Phasen. +- **Entry-Raum-Gate (AKTIV, `[trading] entry_room_atr=0.6`, gemessen + `backtest_entryroom.py` 2026-07-16):** Richtungssignal → WARTEN, wenn das + **Gegenlevel** (M5-Pivot in Trade-Richtung, trainingsgleich zum S/R-Auto-Close) + näher als X×ATR_M5 liegt. Befund (seq. Sim MIT S/R-Close-Exit, Echtkosten, + 2 Halbjahre): **monoton in BEIDEN Hälften** — Raum <0,3: ØR −0,119/−0,067 + (PF 0,42/0,66!) · 0,3–0,6: −0,100/−0,059 (⚠ 79–82 % WR und trotzdem PF<1 = + Klein-Close-Falle) · 0,6–1,0: −0,123/−0,060 · ≥1,0 bzw. „kein Gegenlevel" positiv. + Kosten-Arithmetik, kein Signal-Timing (deshalb robust, anders als die 9 verworfenen + Filter): Ertrag am Level gedeckelt (63 % Containment) − 0,265×ATR Kosten < 0. + Auslöser: Live-Trades mit Sofort-/Klein-Close (0-min-Trade +0,08 direkt am Level; + 4 der 5 sr_closes desselben Tages waren dagegen RETTER — Exit NICHT anfassen). + Umsetzung: `wave._room_gate` (in `refresh_market` VOR `_confirm_breakout`; + `set_entry_room`, Grund „kein Raum: Widerstand X nur 0,4×ATR entfernt …"). + Backtests via `_build` bleiben gate-frei (kein pb_levels-State). 1,0 wäre gemessen + noch besser (H1 +415/H2 +266 R), filtert aber ~¾ der Signale → Start 0,6, per + B4-Wochenreport prüfen und ggf. via ini erhöhen. +- **Mindest-Konfidenz-Gate** (`_MIN_CONF=55`): Signal < 55 % → WARTEN. Gemessen + (`backtest_conf.py`): Band **40–54 % = negativer Edge** (−0,025), ≥55 % = +0,058; + Gate hebt Ø-Edge ~+50 % und den Gesamtertrag (entfernt die schwachen Setups, bei + denen zu viele Strafen gestapelt sind — typisch der Lagging-Short am Boden). +- **Signalhäufigkeit** (`backtest_dist.py`): ~57 % der Zeit LONG/SHORT, ~43 % WARTEN. + WARTEN-Treiber: M30-Gegen-Trend (~27 % der Bars, gewollt, Edge×2) + Überdehnung + + Totband. `_STRETCH_MAX` 2,5→**3,5** angehoben (`backtest_stretch.py`: +26 % + Signale, Gesamtertrag steigt; ab 3,5×ATR kippt der Edge negativ). +- **Higher-TF-Gegen-Trend-Filter:** Signal wird verworfen (→WARTEN), wenn der + übergeordnete **M30**-Trend (EMA12/50, Totband `_HTF_DEADBAND=0,15×ATR`) klar + dagegen steht — kein Short im M30-Aufwärtstrend & umgekehrt. Nur für Basis-TF + < M30 aktiv (`_htf_sign`, holt M30-Bars im selben `mt5_lock`). Per Backtest + belegt: Ø-Edge ×2 (+0,012→+0,027), Treffer 52→54 %. Mess-Tool: + `python backtest_htf_filter.py M5 M30 4000 0.15`. +- **Einstiegsqualität (nur Konfidenz/Score, NICHT Richtung):** nach gemessenem + Edge (`backtest_pullback.py`) gestaffelt — **tiefer Pullback** (Kurs durch die + EMA zurück, `near<0`) = bestes CRV (+15, „⭐ tiefer Pullback"); laue Zone direkt + an der EMA (0–0,3×ATR) = schwächster Edge (−3); Trend-Momentum (1–2×ATR) +8. + **Kein** Spitzen-Gate: ein hartes „nur an Wellenspitze"-Filter senkte den + Gesamtertrag (−80 % Trades), darum verworfen. Konfidenz blockt NICHT das Ordern + (nur WARTEN/Gegen-Signal tut das). +- **Multi-TF-Konfluenz (nur Konfidenz/Anzeige):** M30 **und** H1 bestätigen die + Richtung → +12, „⭐⭐ Konfluenz M30+H1"; H1 dagegen → −8. Per Backtest belegt: + Ø-Edge ×3,5 (+0,016 nur-M30 → +0,056 M30+H1). `_htf_sign` holt zusätzlich + H1-Bars. Mess-Tool: `python backtest_improve.py 8000`. +- **Tageszeit-Gate = AUS geschaltet (User-Vorgabe 2026-07-22, `[trading] dead_hours=` + leer — bewusst GEGEN die Messung).** Jetzt **config-steuerbar** (`wave.set_dead_hours`, + Komma-Liste von Stunden; leer=aus). Gemessener Default im Code = `0,1,2,3,4,5,6,7,12,16` + (die ini überschreibt auf leer). ⚠ 12/16 waren zuletzt am 2026-07-21 + (`backtest_hourly_split.py`) noch beidhälftig negativ — der User schaltet das Gate + trotzdem ab. Reaktivieren = ini-Stunden eintragen. Der **EIA-Blackout (Mi 15:30–16:30) + bleibt separat aktiv** (eigenes Event-Gate, nicht Teil des `dead_hours`). Historie/ + Messung (Gate war 11–14 → 12+16 → aus → zurück → jetzt wieder aus): + Nach der **Echtkosten-Messung** (`backtest_realcosts.py`, Kosten = Bar-Spread/ATR + statt pauschal 0,1×ATR — Ø real 0,265!): **Nacht 0–7 = Kostenfalle** (konstanter + Spread ÷ niedriger Nacht-ATR = 0,32–0,50×ATR, in beiden Hälften negativ; reine + Arithmetik) + **12/16 Uhr 3× unabhängig negativ**. Politik „0–7+12" verbessert + BEIDE Hälften (H1 −4038→−1870, H2 +100→+1191). **Robust positiv nur 21–22 Uhr.** + ⚠ Die „15 robusten Negativ-Stunden" NICHT blocken — in-sample-zirkulär (Overfit). + breakout_k=0,3 auch unter Echtkosten bestätigt (H1 einziges positives k). Historie + (Gate war 11–14, dann 12+16, dann auf User-Wunsch aus, jetzt gezielt zurück): + - **Von 11–14 auf 12+16 umgestellt (gemessen+verifiziert, `backtest_hourly.py` + + `backtest_hourly_split.py`, 80k M5 in 2 Halbjahren):** Netto-R je Stunde zeigt + **12 Uhr (−0,11/−0,12) und 16 Uhr (−0,17/−0,10) in BEIDEN Hälften negativ**; + **Std 13 kippt das Vorzeichen** (−0,048/+0,039 = Rauschen), 11/14 ~breakeven. Das + alte Pauschal-Gate 11–14 blockte harmlose Stunden und ließ die real schlechte 16 + laufen. **Gate 12+16 schlägt 11–14 im Gesamt-Netto-R in beiden Zeiträumen** + (H1 −1357→−1253, H2 +1562→+1799) und handelt dabei mehr Trades. Intraday-Edge + generell dünn; **Löwenanteil in US-Session 21–22 Uhr** (+0,25/+0,49). + - **Reversal-Ausnahme vom Gate = VERWORFEN (gemessen, `backtest_deadhour.py`):** + Reversal in der Dead-Hour nur **+0,062×ATR brutto** (vs +0,190 außerhalb) → nach + Kosten (~0,1×ATR) negativ. Trade-①-Gewinner war Varianz. Gate gilt auch für Reversal. +- **EIA-Blackout (kausales Event-Gate, AKTIV):** **Mittwoch 15:30–16:30 Berlin** + (Vorlauf der EIA-Lagerdaten 16:30 = 10:30 ET, Offset ganzjährig 6 h) → hartes + WARTEN („EIA-Blackout …"). Gemessen (`backtest_events.py`, 2 Halbjahre): dieses + Fenster in BEIDEN Hälften netto negativ (−0,147/−0,088 vs rest) — deckt sich mit + dem alten „16 Uhr"-Befund. eia_post/api_pre/api_post NICHT robust → nicht geblockt. + Nur im Live-Pfad (`hour≠None`), Backtests bleiben gate-frei. Best-Practice + „Event-Blackout" statt Pauschal-Gate. +- **Winkel-vs-EMA-Warnung (nur Konfidenz):** Regressions-Winkel der Basis-TF gegen + die EMA-Richtung → −10 „⚠ Winkel gegen EMA (mögliche Wende)" (nachlaufende EMA!); + dafür → +5. `backtest_angle.py`: dafür/neutral +0,055 vs dagegen +0,025; als Gate + aber Gesamtertrag-negativ → bewusst nur Konfidenz/Warnung (Hilfe fürs manuelle + Überspringen der Lagging-Shorts). +- **Höher-TF-Winkel-Filter (M30+H1-Winkel) = VERWORFEN (gemessen, + `backtest_htf_angle.py`):** Signal verwerfen, wenn M30/H1-Regressionswinkel gegen + die Richtung steht → in JEDER Schwelle/Variante **schlechter**. Die entfernten + Signale (Short bei aufwärts-Winkel) haben **höheren** Edge (+0,04–0,05) als die + behaltenen (+0,02) — es ist der profitable Pullback-Setup. Filter kostet ~⅔ des + Gesamtertrags (+287→~+70). Gegen-Winkel-Shorts NICHT filtern; einzelne große + Verluste (z. B. −37 an einem H1-Auf-Tag) sind Varianz, Schutz über Notfall-Stop/ + Sizing, nicht über Signal-Filter. + - **Auch als Konfidenz-STRAFE verworfen (`backtest_confluence_angle.py`):** statt + Hard-Filter den Konfluenz-Bonus streichen/Konf senken, wenn M30/H1-Winkel + dagegen → borderline-Signale fallen unters 55-Gate. Ergebnis: in JEDER Variante + **schlechter** (Δ −2500…−3467 R); die entfernten Gegen-Winkel-Signale haben + ØR **+0,26…+0,32** (die BESTEN), die behaltenen +0,12. **3× gemessen** (M5-Winkel, + Höher-TF-Winkel-Filter, Konfluenz-Strafe): „nicht gegen den Winkel shorten" + macht den Bot IMMER schlechter. **Nicht mehr am Signal drehen** — Edge ist klein + & varianzlastig; der Hebel ist **Sizing (risiko-basiert) + viele Trades**, nicht + der Filter. + - **H1-STÄRKE-Filter (nicht Winkel!) = VERWORFEN (gemessen `backtest_short_h1filter.py`, + 2 Halbjahre, 2026-07-15):** Motiviert durch Live-Befund „SHORT-Empfehlungen 31 % WR + in Aufwärts-Regime" (LONG 62 %, SHORT 31 %, letzte 40 MIT-Trades). Idee: Signal + verwerfen, wenn es gegen einen **starken H1-Trend** (|H1 EMA12−EMA50| > thr×ATR_H1) + läuft. **Fällt durch:** die weggefilterten Signale flippen das Vorzeichen zwischen + den Hälften — thr 0,5: ØR **H1 −0,198 / H2 +0,001**; thr 1,0: **−0,069 / +0,013**; + thr 1,5: **+0,029 / −0,063**. In KEINER Schwelle robust negativ in BEIDEN → Filter + hätte in H2 (positive/breakeven drops) **Geld gekostet**. **7× belegt: die 31 %-SHORT- + Schwäche ist REGIME (aktueller Uptrend), kein Signalfehler** — in H2 tragen dieselben + Gegen-H1-Shorts. Kein Filter; der Hebel bleibt Sizing + Notfall-Stop + Diskretion + (User-WARTEN-Übersteuerungen liefen 68 % WR / +259 €). + - **ER-Chop-Gate = VERWORFEN (gemessen, `backtest_chopgate.py`, 2 Halbjahre):** + Signal→WARTEN wenn Efficiency Ratio der letzten N Bars < X. Chop-Signale sind NICHT + robust schlechter — Effekt kippt: H1 Trend leicht besser (Ø-R +0,070 vs +0,053), + H2 **umgekehrt** (Chop +0,138 vs +0,129). Gate wirft zudem ~70 % (profitables) + Volumen weg (ΣR +2299→+836). Das Totband filtert Extrem-Chop schon (ER-0,02-Tag = + 71 % WARTEN); Rest-Signale = Varianz. **5× belegt: Signal-Filter tragen nicht.** +- **Vola-Abzug (nur Konfidenz):** hohes ATR (oberes Terzil, `_ATR_HIGH≈0,27`) = + schwächster/negativer Edge → −8 „hohe Vola". Schwelle regime-abhängig → kein Gate. +- **Reversal-Lockout/-Persistenz = VERWORFEN (gemessen, `backtest_revlock.py`):** + Gegen-Signale **kurz nach** einem Reversal sind sogar **besser** als normale + (+0,072 vs +0,018 bei N≤6) → Reversal-Signale NICHT sperren/halten. Einzelne + Verluste (Bottom-Short nach REV) sind Varianz, kein Muster. +- **Rec-Logging:** `engine._run_analysis` → `history.log_recommendation` (Throttle + intern) füllt die `recommendations`-Tabelle (Signal/Score/Conf/Winkel/Setup) für + Tages-Reviews (`analyze_today.py` rekonstruiert sonst aus M5-Bars). +- **Reversal-/BOUNCE-Einstieg (antizyklisch, markiertes ZWEITsignal):** **überdehnt** + (|stretch|≥`_REVERSAL_STRETCH`=**3,0**, **entkoppelt** von Anti-Überdehnung + `_STRETCH_MAX`=3,5) **UND** Regressions-Winkel hat gedreht → Einstieg in + **Winkel-Richtung, gegen die EMA** (überverkauft + Winkel auf → LONG; überkauft + + Winkel ab → SHORT). Setup `WAVE_REV_*`, Grund „🔄 Reversal"; hebt Anti-Überdehnung + **und** M30-Filter bewusst auf. **Schwelle 3,5→3,0 gesenkt** (User wollte mehr + Bounces; `backtest_bounce.py`): Ø-R **+0,185**, PF 1,35, Treffer 70 %, Worst −2×ATR + (SL-gedeckelt), ~1,5× mehr Bounces als 3,5 — profitabel, aber **schwächer als + Trend** & **regime-anfällig** (trägt in Seitwärts/Mean-Reversion, blutet in starken + Trends). Nachrangiges, markiertes Setup. **Bounce-Status — Multi-TF** im Wave- + Snapshot (`wave.bounce = {state, dir, tf}`): scannt **M1/M5/M15/M30** (`_bounce_one`, + Aggregat in `refresh_market`, ~15 s-Throttle via TF-Ampel-Fetch), nimmt den + **stärksten** (aktiv>erwartet, dann größte Überdehnung). **`expected`** = überdehnt + (≥`_REVERSAL_STRETCH`), Winkel noch NICHT gedreht · **`active`** = + Winkel gedreht. + `dir` LONG (überverkauft) / SHORT (überkauft), `tf` = Quelle. Frontend `#wave-bounce` + als **„Überdehnung"-Warn-Gauge** (NICHT „Bounce/Signal"): grau „keine" · bernstein + „⚠ überdehnt … Reaktion nach oben ↑ / nach unten ↓ möglich" · kräftiger bei + `active` (+ TF). **Kein + grün/rot** (kein Trade-Trigger). **Reversal-Konfidenz-Boni = GEPRÜFT & BEHALTEN + (gemessen `backtest_revconf.py`, 2 Halbjahre, 2026-07-15):** Befund war, dass + „starker Trend" (+10) und „⭐ tiefer Pullback" (+15) beim Reversal semantisch + GEGEN das Signal stehen und Reversals so leichter durchs 55er-Gate kommen. + Korrektur (Boni beim REV streichen) **fällt durch**: die dadurch rausfallenden + Reversals kippen das Vorzeichen — H1 ØR **−0,323** (127 St., Fix ok), H2 ØR + **+0,109/PF 1,16** (1608 St., Fix hätte die BESSEREN gekappt, WR 45 %). Nicht + robust → Konfidenz-Berechnung bleibt. **8. verworfener Signal-Eingriff.** Die + hohe REV-Conf-Anzeige (~75–80 %) ist also kein Gate-Fehler; wer will, liest die + Reason-Liste („Reversal" + „hohe Vola") als Risiko-Kontext. **Gemessen (`backtest_bounce_reliability.py`): + Richtungs-Trefferquote der „aktiv"-Anzeige ~50 % (Münzwurf), Ø-Vorlauf ~0×ATR über + alle TF & H1-Regime → reiner Überdehnungs-Kontext, KEIN Signal.** (Die früheren + „70 %" aus `backtest_bounce.py` waren exit-getrieben, nicht Richtungs-Vorhersage.) +- **Timeframe:** `[trading] tf_select=heuristic` → `engine._choose_tf` wählt jede + Minute die TF mit klarstem Trend aus dem **Band `[tf_min … tf_max]`** (leichter + Höher-TF-Bias). **Einstellung: `tf_min=M5`, `tf_max=M30`** (M1 raus: gemessen + ~0 Edge; M30 zugelassen, damit der Bot in einem M30-Trend nicht festhängt, wenn + M5/M15 gegen-Bouncen — sonst Patt: Long geblockt, Short fehlt). **H1 bleibt nur + Kontext** (Filter/Konfluenz). Start-TF wird ins Band geklemmt. `agent` = LLM + wählt; `M1..H1` = fest. Der **Agent steuert die TF nicht mehr**. +- **Positionsgröße = margin-basiert 80 % (User-Vorgabe 2026-07-20, `risk_pct=0`, + `margin_buffer_pct=80`):** `calc_lots` setzt die Lots auf ~80 % der freien Margin. + Verlauf 90→95→75→(1,5 % risiko, wenige Stunden am 2026-07-20)→**80 % margin** — + der User hat die Margin-Wahl damit 3× getroffen (bewusst; nicht ungefragt umstellen). + ⚠ Aggressiv: der −95,48-€-Nacht-Trade 2026-07-20 (News-ATR → SL ~1,0 $ breit × + 1,03 Margin-Lots ≈ 11 % Konto) ist der Referenzfall; mit `risk_pct=1.5` wären es + ~0,15 Lots / ~−14 € gewesen (`backtest_sizing.py`: Margin-Sizing bis 36 % + Quasi-Ruin, 1–2 % = 0 %). Risiko-Modus bleibt gebaut & abgesichert (Guards s. u.), + Umschalten = `risk_pct=1.5`. Mechanik: `trader._send_locked` + bestimmt **erst den SL**; bei `risk_pct>0` → risiko-basiert (`calc_lots_risk`, + Verlust ≈ risk_pct % Equity, Margin als Deckel) = der gemessene Fix gegen die + Verlust-Asymmetrie. **Umschalten via `[trading] risk_pct` (z. B. 1.5 = überlebbar).** + ⚠ **Risiko-Modus-Guards (Fix 2026-07-19):** `calc_lots_risk` rundet NICHT mehr auf + `volume_min` auf (unter Mindestlot → 0.0 statt still mehr Risiko), und + `_send_locked` fällt bei `risk_pct>0` NICHT mehr still auf Margin-Sizing zurück + (75 % wäre ein Vielfaches des gewollten Risikos) — der Trade wird stattdessen mit + Fehlermeldung abgelehnt. Bei `risk_pct=0` (aktuell) unverändert margin-basiert. + Harter Broker-SL immer mitgeschickt; adoptierte Fremd-Trades bekommen Schutz-SL. + - **Sizing-Simulation (`backtest_sizing.py`):** 90 % Margin = Wachstums-optimal, + ABER **36 % P(DD>80 %)** = Ruin-Risiko. Risiko 1–2 %/Trade = überlebbar (0 % + Quasi-Ruin). Exit-Asymmetrie gemessen `backtest_exit2.py` (Live-Exit Trail 1,5 + ist OK; Problem war Sizing + manuelles Gewinner-Kappen, NICHT die Exit-Logik). +- **SL:** Band **1,8–2,2×ATR** (Ziel 2,0; `INIT_SL_MIN/MAX_ATR`). **Breakeven ab + +1,3×ATR** (`_BREAKEVEN_ATR`; war 0,6 → 21 % Breakeven-Scratches, gemessen + `backtest_exit.py be`: 1,3 = Plateau-Optimum → Scratch 5 %, Ø-R +15 %, kein + Tail-Risiko). **Früh-Ausstieg = AUS** (`_ADVERSE_EXIT_ATR=0`). Trailing + Init/Trail/Lock. **Teil-Exit AUS** (`_PARTIAL_TP_FRAC=0`, User-Vorgabe) → am TP + wird der **komplette** Trade geschlossen (kein 50 %-Runner mehr). + - **Früh-Ausstieg NUR bei Trades OHNE Signal-Deckung = VERWORFEN (gemessen + `backtest_adverse_nosignal.py`, 2026-07-23, Auslöser −57,34-€-Trade: manueller + LONG gegen WARTEN, KEIN Squeeze/Signal-Flip beteiligt):** Hypothese war, dass ein + Adverse-Exit bei signal-ungedeckten Trades (Totband/WARTEN-Zone, B0 zeigt live + 39 % WR dort) helfen könnte, auch wenn er pauschal auf ALLE Trades schadet. **Fällt + durch — bei JEDER Schwelle (0,5/0,75/1,0/1,5×ATR), in BEIDEN Populationen (SIG/ + NOSIG), fast durchgehend in BEIDEN Hälften negativ** (einzige Ausnahme: SIG H1 + X=1,5 knapp +11 R, aber H2 dort −46). WR bricht von ~38–43 % auf 18–26 % ein — der + Früh-Ausstieg kappt zwar einige Trades vor dem vollen SL, schneidet aber weit öfter + Trades ab, die sich erholt hätten; NOSIG-H2 war ohne Eingriff sogar leicht profitabel + (ΣR +80, PF 1,15) und wird durch jede Adverse-Schwelle verschlechtert. Hypothese + „schwächere Population profitiert eher" widerlegt. Keine Änderung. + - **Trailing-Optimierung geprüft = KEINE Änderung (gemessen, `backtest_trailing.py`, + 2 Halbjahre, echte Phasen-Sim):** Die Live-Config (`_TRAIL_START_ATR=0,3` · + mult 1,5 M5 · Breakeven 1,3) hat die **beste Verlustseite** (bestes H1, Worst nur + −1,5×ATR). Trailing trägt klar: ohne Trailing ΣR −1313/Worst −2,0 vs. Live +1777/ + Worst −1,5 (~+3000 R **und** kleinerer Einzelverlust). **Breiter ziehen** (mult + 2,0–2,5) oder **Trail später starten** (0,3→1,0) macht H1 schlechter/Worst größer; + der frühe Trail-Start ist **netto schützend** (holt „läuft-schief"-Trades billiger + raus). Einzige mithaltende Variante (start0,6) kippte zwischen den Läufen = + Rauschen. → Ausschütteln ist Varianz, nicht Logikfehler. + - **Time-Stop (AKTIV, gemessen `backtest_timestop.py`):** Position wird geschlossen, + wenn sie nach **120 min noch in Phase „Init"** hängt (HW-Profit nie ≥0,3×ATR = + Whipsaw-Opfer; `trailing._TIMESTOP_MIN=120`, 0=aus, `closed_by=timestop`). + Verbessert BEIDE Hälften (H1 +98→+162, H2 +2191→+2298 R), Worst unverändert; + kürzere Fenster (30/60 min) kippten zwischen den Hälften = Rauschen. Dank + Phasen-Ratsche ist „Init nach 2 h" exakt „nie Trail-Start erreicht". + - **ATR-Floor 0,12→0,06 gesenkt (gemessen, `backtest_atrfloor.py`, 2 Halbjahre):** + Die reale Vola fiel unter den alten Floor (H1: **81 % der Signale ATR<0,12** → + Floor band dauernd, Exit-Distanzen künstlich ~1,3–1,6× breit, dazu ~35k + Log-Warnungen je 5-MB-Rotation). Floor 0,06 in **beiden** Hälften besser + (H1 −153→−27 Pts, H2 +512→+554); ganz ohne Floor nur marginal besser → 0,06 als + Schutz behalten. Floor-Warnung jetzt **gedrosselt** (max. 1×/5 min). + - **Befund B — umgesetzt (Exit-Simulation `backtest_exit.py`, Doku + `docs/exit-simulation.md`):** altes Band 1,2–1,5 war zu eng (MAE ~1,7×ATR, + 31–37 % Frühstopps). 2,0×ATR hebt Ø-R +32 %, PF 1,33→1,39, Frühstopps →25 %, + Worst-Case auf 2,0×ATR gedeckelt. Breiter (3–4×ATR) wäre im Schnitt noch + besser, aber Tail-/Drawdown-Risiko → verworfen. ⚠ Da Sizing margin-basiert + (90 %, nicht risiko-basiert), schlägt die SL-Breite voll auf den EUR-Verlust + durch. Qualität-abhängiger SL NICHT belegt (MAE über alle Buckets gleich). + - **S/R-Close (am Level schließen) = pauschal 2× VERWORFEN, als GEMESSENES + Segment aber AKTIV:** Pauschal (`backtest_srclose.py`, Echtkosten, 79k Signale) + verdoppelt Close@Level die WR (35→67 %), verschlechtert aber PF/ΣR in BEIDEN + Hälften (0,78→0,67 · 0,88→0,76) = Gewinner-Kappen. **ABER** (`backtest_srbreak.py`, + 47k Touches, User-Idee „P(Durchbruch)>60 % → laufen lassen"): Basisrate + P(break)=36–37 %; das **Anlauf-Momentum trennt robust in beiden Hälften** — + Kurs KRIECHT ans Level (letzte 6 Bars <0,3×ATR) → P(break) nur 26 %, **Close + schlägt Laufenlassen Ø +0,12 R**; mit Schwung (≥0,3) → laufen lassen (41–43 %). + Volumen/Touch-Zahl trennen NICHT robust. + - **Kalibriertes P(Durchbruch)-Modell (2026-07-13, `analyze_srwaves.py` + + `backtest_srclose_prob.py`):** Containment-Befund — **~68 % der Wellen erreichen + das gegenüberliegende Level, davon brechen nur ~37 % durch → ~63 % prallen ab** + (robust beide Hälften). P(break) ist **vorhersagbar & kalibriert**: Logit auf 4 + Merkmalen (Anlauf-Momentum 6/3 Bars ·d, mit-Trend, Level-Distanz), auf H1 + trainiert, auf H2 **AUC 0,71 + Kalibrierung trifft** (pred 17→21 %, 49→50 %, + 69→76 %). Dominant = 3-Bar-Beschleunigung (`_PB_W` mom3 +0,78). **R-Ertrag + (Echtkosten):** Regel „close wenn P(break) < X" schlägt reines Trailing in BEIDEN + Hälften massiv (+6000/+7600 R); **User-Schwelle 0,60 im Optimum-Plateau (0,50–0,60 + ~gleich), validiert.** ⚠ Impliziert Strategiewechsel: ~90 % der Level-Touches + werden geschlossen (Trendfolger→Level-Scalper) — trägt, weil echte Kosten (0,265× + ATR) + 63 % Containment das Laufenlassen teuer machen. + - **S/R-Close SIGNAL-gegated („bei mit-Trend halten statt schließen") = VERWORFEN + (gemessen `backtest_srclose_signal.py`, 2026-07-22, User-Idee):** Vorschlag war, + den S/R-Close zu unterdrücken, solange die Empfehlung noch in Trade-Richtung zeigt + (halten bis WARTEN). **Fällt klar durch — in BEIDEN Hälften ~−5300…−5700 R + schlechter** als der reine P(break)-Close. Grund/Datenbeleg: ~85 % ALLER Level- + Touches passieren, WÄHREND die (nachlaufende) EMA noch „in Richtung" zeigt + (23,8k/20,1k von 27,8k/26,2k Touches) — genau dort HÄLT das Level aber. Diese + Touches geschlossen = ΣR +11703/+13606, geHALTEN = nur +5978/+8347. Die + Trend-Empfehlung lagt → sie „bestätigt" ausgerechnet am Abpraller. Das kalibrierte + P(break)-Modell (hat „mit-Trend" als Merkmal) macht das schon richtig; es mit dem + groben EMA-Signal zu übersteuern kostet ~⅔ des S/R-Close-Edges. **12. verworfener + Signal-Eingriff.** + - **Auto-Flip-Close = VERWORFEN (gemessen `backtest_flipclose.py`, 2 Halbjahre, + sequentielle 1-Positions-Sim, 2026-07-16):** User-Taktik-Kern „Position im Plus + automatisch schließen, wenn die Empfehlung dreht" (heute nur Alarm). KEINE Variante + (minR 0/0,3/0,5×ATR, auch-im-Minus) hält in beiden Hälften ØR & ΣR ≥ Basis: + H1 ΣR −278→−280…−417, H2 **+174→+119…+144** (kostet 17–32 % des Ertrags). + Mechanik: WR steigt 43→50 %, Ø-Gewinn schrumpft = **Gewinner-Kappen** — die + nachlaufende EMA dreht oft mitten im Pullback, der Trade wäre weitergelaufen. + Flip bleibt **Alarm + menschliche Entscheidung** (User-Übersteuerungen 68 % WR). + Der Rest der User-Taktik (Ziel=nächstes S/R, weiterlaufen wenn stark) IST der + P(break)-Auto-Close; der „3 %-Margin-Anker" = EUR-Schwelle, Klasse „verliert". + - **Mindestgewinn für den S/R-Close = VERWORFEN (gemessen `backtest_srclose_prob.py`, + Mindestgewinn-Sektion):** „nur closen wenn ≥minR im Plus" verliert **monoton in + BEIDEN Hälften** (minR 0,2: −444/−213 · 0,5: −1425/−854 R vs minR=0). Die vielen + kleinen Gewinne SIND der Edge — übersprungene Klein-Closes prallen am (haltenden!) + Level ab und enden schlechter. Kleine EUR-Beträge = Positionsgröße, kein Exit-Thema. + 2 Halbjahre, Echtkosten):** LONG an Support / SHORT an Resistance, gegated mit + P(Level hält), Ziel = Gegenlevel — **verliert in BEIDEN Hälften** (FIX H1 −3393/ + H2 −2242, +Trailing/-nur-Trailing ebenso negativ; WR 46–53 %, PF<1). **Asymmetrie: + P(break) taugt zum EXIT** (Trade schon mit-Trend im Plus am Gegenlevel → kassieren), + **nicht zum ENTRY** (antizyklisch am Extrem = Münzwurf-Richtung, 6× belegt). Einstieg + bleibt mit-Trend (Pullback). **Trailing bleibt** — nicht mehr Standard-Exit (das ist + der S/R-Close), aber Manager für die ~10 % Durchbruch-Runner (P≥Schwelle) + Fallback + für Trades ohne erreichbares Gegenlevel. + - **Umsetzung:** `engine._p_break(mom6,mom3,wt,dist)` (eingebettete `_PB_MU/SD/W`); + `wave_rec.snapshot` liefert **`pb_feats` {mom6, mom3, ema_diff, atr} — FEST aus M5** + (aus dem ~15-s-Turn-Fetch gecacht, ATR-Floor 0,12 wie im Training). ⚠ **Bugfix + 2026-07-13:** vorher kamen die Features aus der Wave-TF (Heuristik bis M30 → mom6 + war 3-h- statt 30-min-Momentum, ATR 2–3× → P falsch skaliert) und das Level aus dem + Misch-Set `_sr_levels` (Zonen/Gaps/Tageslevel). **2. Bugfix gleicher Tag:** die + M15-Cluster-Level (`market.sr`, SR_MIN_TOUCHES) sind in frischen Trends oft LEER → + Hint verschwand komplett. Jetzt **trainingsgleiche Level**: `wave.pb_levels` = rohe + **M5-Pivots (k=3, ~300 Bars)** aus dem Turn-Fetch (M5 dafür 320 Bars tief), Highs + in `_m5_hl` gecacht. Am Rally-Hoch gibt es für LONGs korrekt KEIN Gegenlevel → + kein Hint (nichts zu closen). ⚠ **3. Bugfix 2026-07-15:** das Close-Level kommt + jetzt aus **`_draw_levels`** (dieselbe geclusterte+Hysterese-Linie wie Chart & + Dashboard) statt aus den rohen `pb_levels` — sonst feuerte die Meldung an einem + anderen Level als die gezeichnete Linie (User: „passt nicht zusammen"). P(break)- + Features (M5) unverändert, nur `level`/`dist` verschieben sich um Cents. + **`_sr_close_hint`** (Anzeige ≠ Auslösung, Fix + 2026-07-14 — das 0,15×ATR_M5-Fenster ≈ 0,03 $ war als reine Anzeige unsichtbar + klein): Hinweis erscheint ab **≤0,6×ATR Annäherung** (blaue „nähert sich"- + Vorwarnung mit Live-P(break) + `gap_atr`); **Auslösung `close=true` nur AM Level** + (≤0,15×ATR = gemessener Touch) und `p_break < [trading] sr_close_pbreak` + (Default 0,60, Band 0,30–0,90). Snapshot `sr_close_hint {level, p_break, + at_level, gap_atr, close, confirm}`. Hinweis existiert NUR mit offener Position. + **`confirm` = Bruch-BESTÄTIGUNGS-Preis** (Fix 2026-07-22, User-Frage nach einem + Trade, der am Level geschlossen wurde, obwohl der Kurs „durchgebrochen" war): + `level ± 0,5×ATR` in Trade-Richtung — **dieselbe Definition wie beim Modell- + TRAINING** (`_BRK_ATR=0,5`/`_BRK_W=12` in `backtest_srclose_prob.py`/ + `analyze_srwaves.py`). Ein Docht, der die nackte Levelzahl nur um wenige Cent + überschreitet, zählt NICHT als Durchbruch — reales Fallbeispiel 2026-07-22: + Close bei Level 85,943 (P(break) 31 %), Kurs wischte auf 85,994 (< Bestätigung + 86,017 = Level+0,5×ATR) und fiel zurück → nach Modell-eigener Definition eine + ABLEHNUNG, keine Bestätigung; die Vorhersage war korrekt. `confirm` jetzt in der + Log-Zeile („— Bestätigung erst ab X") UND im Frontend-Hinweistext (`#pos-srhint`, + alle 3 Zweige: Vorwarnung/Close/Laufen) sichtbar — macht den Unterschied + „Kurs > Levelzahl" vs. „strukturell bestätigter Bruch" auf einen Blick klar. + **Live-Prognose-Tracking (2026-07-23, User-Wunsch „mitloggen wie oft richtig/ + falsch"):** jeder frische Level-Touch (`at_level` False→True) loggt EINMAL die + aktuelle Vorhersage (`bounce` wenn P(break)0`) wenn `close=True`, 1×/ + Ticket, wirkt bei gesperrtem Handy. **Optionaler Mindestgewinn** (`[trading] + sr_close_min_gain` Default 0=aus · UI `#srmin-input` · `POST /api/srclose_min` · + `set_sr_close_min_gain` · Snapshot `sr_close_min_gain`): unter der EUR-Schwelle + wird am Level NICHT geschlossen. ⚠ Gemessen ist OHNE besser (Verworfen-Eintrag) — + Opt-in auf User-Wunsch, UI-Status warnt. **Automatisch auf X % des Einsatzes + (User-Vorgabe 2026-07-23, `[trading] sr_close_min_gain_pct=3.0`, 1%→3% am selben + Tag nachgezogen zusammen mit dem margin-%-Notfall-Stop, s. u.):** bei JEDER + echt neuen Position (nicht beim Wiedererkennen nach Neustart) wird `sr_close_ + min_gain` frisch auf `pct% × Margin dieser Position` gesetzt (`_check_auto_close`, + Margin kommt aus `trader.refresh()` unmittelbar davor im selben `_pos_loop`-Tick) — + überschreibt den manuell gemerkten Wert, persistiert via `_save_runtime_state`. + 0 = aus (dann bleibt der manuell gesetzte/gemerkte Wert maßgeblich wie vorher). + ⚠ Gleiche Kopplungs-Regel wie beim festen Mindestgewinn: skaliert nur die Größe + desselben bereits gemessen-suboptimalen Mechanismus, kein neuer Risikotyp. Log + „Auto-Close armiert …" (getrennt) + „Gewinn-Close armiert … +X (3% Margin Y)". + **Gate verifiziert (2026-07-23, User-Nachfrage „darf nicht schließen bevor der + Wert erreicht wurde"):** `_check_sr_close` prüft `pnl < sr_close_min_gain → return` + **bevor** `_sr_close_hint`/`trader.close` überhaupt aufgerufen werden — isoliert + mit 6 synthetischen Szenarien getestet (unter/exakt-auf/über Schwelle, P(break) + hoch trotz Profit, Schwelle aus, Verlust-Trade) → alle korrekt. **Dabei echten + Bug gefunden & gefixt:** die 1%-Berechnung lief nur EINMALIG beim Ticket-Wechsel + (`if ticket != self._last_pos_ticket`); war `order_calc_margin` genau in diesem + einen Tick 0/nicht verfügbar (Broker-Race direkt nach Order-Fill), blieb + `sr_close_min_gain` für die GESAMTE Laufzeit der Position auf dem STALEN Wert der + vorherigen Position hängen (der Ticket-Wechsel-Zweig feuert nur 1×, keine zweite + Chance) — im ungünstigen Fall ein zu NIEDRIGER Alt-Wert, der den Trade dann exakt + zu früh schließt. **Fix:** neuer State `_sr_close_min_gain_armed_ticket` + (getrennt vom Notfall-Stop/TP-Restore-Zweig) — die %-Berechnung läuft jetzt JEDEN + `_pos_loop`-Tick erneut, bis sie für das aktuelle Ticket einmal erfolgreich war + (Margin>0), statt nur im Ticket-Wechsel-Moment. Wiederhergestellte Positionen + nach Neustart markieren sich sofort als „armiert" (kein Überschreiben des + persistierten Werts) — mit Test verifiziert. Log `🎯 S/R-AUTO-CLOSE` + Telegram + Toast + (`sr_close_count`), `closed_by=sr_close`. UI Position-Kachel: bernstein „Durchbruch + nur X% → Bot schließt" bzw. grün „Durchbruch X% wahrscheinlich → läuft weiter". + Abschalten `auto_sr_close=false` / `set_sr_autoclose(False)`; Schwelle via + `sr_close_pbreak`. Kein pauschaler S/R-Close (2× verworfen) — nur mit P(break)-Gate. + - **S/R-verankerter SL = KEIN Edge (gemessen, `backtest_sl_method.py`):** + Pivot+Band vs. „S/R ∓ ATR/0,10-Puffer" vs. fest 2,0×ATR — **gedeckelt alle + identisch** (Ø-R 0,196–0,201, PF 1,38–1,39, Worst −2,2×ATR): der **Band-Cap + [1,8–2,2]×ATR dominiert**, die Verankerung ist Rauschen. Nur **ungedeckelt** + sieht S/R besser aus (Ø-R 0,249) — aber Worst-Case **−11,4×ATR** (Kontokiller + bei 90 %-Sizing) → verworfen. Aktuelle SL-Logik bleibt. + - **SL-ATR-Timeframe bei Squeeze-Trades (M5-Signal, aber SL_TF fix M15) = GEPRÜFT, + KEINE Änderung (gemessen `backtest_sl_tf_mismatch.py`, 2026-07-23, Auslöser + −89,36-€-Nacht-Trade):** `_calc_sl_tp` sizt den Initial-SL IMMER aus M15-ATR, + unabhängig vom Signal-TF — bei Squeeze (M5-Signal) kann das stark divergieren + (real: M5-ATR fiel 0,29→0,10 — genau die Kompression, die den Squeeze auslöste — + während M15-ATR noch bei ~0,36 lag → SL 0,787 statt ~0,22 bei M5-Sizing). Der + validierte Squeeze-Backtest (`backtest_breakout_squeeze.py`) nutzt durchgehend + M5-ATR für den SL — Live weicht hier vom Backtest ab. **2-Stichproben-Test der + Umstellung auf M5-ATR-SL bei Squeeze-Trades: GEMISCHT** — H1 leicht besser + (ØR +0,167 vs +0,064, ΣR +35 vs +13), H2 (größere, aktuellere Stichprobe) klar + SCHLECHTER (WR 51 % vs 59 %, ΣR +281 vs +323) → fällt nach der beidhälftig- + robust-Regel durch. Der breitere M15-SL kassiert gelegentlich einen vollen + Verlust wie diese Nacht, verhindert aber mehr vorzeitige Ausstoppungen bei + normalen Rücksetzern — netto vorteilhaft. **Keine Änderung.** +- **Inter-Market (Brent-Bestätigung / DXY-Gegenwind) = VERWORFEN (gemessen + `backtest_intermarket.py`, 2026-07-19, letzte ungetestete Indikator-Klasse):** + 1366 Trades, seq. Sim, Live-Exit, Echtkosten, 2 Halbjahre; Buckets nach Zustand + am Entry (Symbole: `SpotBrent`, `USDX`). **Beide Hypothesen kippen zwischen den + Hälften:** (A) Brent-Trend dagegen H1 −0,002 vs H2 −0,187 (H1 sogar BESSER als + „dafür"!), Brent-Mom-1h dagegen H1 **+0,311**/H2 −0,233 = Flip; (B) DXY-Gegenwind + H1 −0,13…−0,19 (schlechter, wie vermutet) aber H2 **+0,13…+0,17 = BESSER als + Rückenwind** — komplett invertiert, klassische Regime-Abhängigkeit (wie H1-Stärke- + Filter/Momentum). Einzig Brent-Mom-3h ist richtungs-konsistent, aber winzig + (Δ~0,06–0,14) und die Schwester-Varianten kippen → nach der vorab festgelegten + Verdict-Regel (beide Hälften UND Varianten-robust) **kein Einbau**. Damit ist auch + die letzte orthogonale Indikator-Klasse durch: **am Signal ist nichts mehr zu + holen — Hebel bleiben Sizing/Exit/Verhalten** (nicht gegen das Signal handeln: + live gemessen 39 % WR / −166 € vs. mit 55 % / +65 €). + `backtest_confluence.py` — MACD/ADX-Richtung praktisch neutral, ADX-Stärke leicht + negativ, ein MACD+ADX-Gate senkt den Edge (−78 % Trades). RSI *bestätigend* ist + kontraproduktiv (RSI *gegen* Richtung = doppelter Edge = der Pullback-Effekt, den + der „⭐ tiefer Pullback"-Bonus schon nutzt). Standard-Oszillatoren also nicht + draufsatteln. Mehrquellen-Redundanz läuft bereits über die Gesamtempfehlung. +- **TradersUnion (TU) aus der Empfehlung ENTFERNT (User-Vorgabe 2026-07-06):** TU ist + Standard-Indikator-Konsens (MA/Oszillator-Ratings einer Website) — **dieselbe Klasse + wie MACD/ADX/RSI = kein Edge**, plus **Latenz** (5m „Strong Buy" während 4h/1d „Strong + Sell") und **nicht backtestbar** (Live-Scrape, keine History). Floss vorher als + Konfidenz-Faktor (`_tu_check` ±6/−8 je 5m/15m) UND als Verdict-Stimme ein — **beides + neutralisiert** (`wave_rec._build` zieht `_tu_check` nicht mehr; `_verdict` ohne + TU-Vote). ⚠ Nebenbefund: der Konfidenz-Pfad hatte **keinen** `_STALE_S`-Check (nur + `trade_signal` hatte ihn) — mit der Entfernung erledigt. TU bleibt **reine Anzeige** + (Snapshot `tu`); `_tu_check`/`_TU_*` als Code erhalten (reaktivierbar). Fetch war + auf 5 min gedrosselt (`_TU_S=300`); **Loop seit 2026-07-19 ganz stillgelegt** + (`_tu_loop` nicht mehr in `start()` — TU ist aus Empfehlung/Verdict/Copilot raus + UND die Kachel ausgeblendet, der Scrape lief für niemanden; reaktivieren = Loop + wieder in die Liste). Elliott loggt nur noch **bei Änderung** (vorher + ~6k identische Zeilen/Logrotation). +- **Order ohne Empfehlung = bestätigbarer HINWEIS, kein Block:** Bei WARTEN/ + Gegen-Signal zeigt das Frontend einen **Bestätigungs-Dialog** („… trotzdem + eröffnen?") — bestätigen = Trade läuft, Abbrechen = kein Trade. **Kein** hartes + Blockieren (überschreibbar). `engine._open` blockt nicht (loggt nur). +- **„Erst messen, dann umbauen":** Strategie-Änderungen per Backtest belegen, nicht + raten. Gegen-Trend verliert; nicht bei WARTEN handeln. **Stichproben ~28 Tage** — + ein Trend-Regime; Befunde später auf mehr/andere History gegenprüfen. +- **Wave-Auto-Trader (Dry-Run) KOMPLETT ENTFERNT (2026-07-23, Code-Review-Fund):** war + seit Langem Verlierer (WR 29 % / PF 0,64) vs. manuelle Trades (WR 56–76 %); UI-Toggle + bereits vorher entfernt (Dashboard-Button steuert seitdem Auto-Squeeze), lief aber + weiter dormant bei jedem Tick mit (toter Ballast). `core/auto_trader.py` gelöscht + (Sicherung: `.removed_backup/`), `engine.self.auto` + alle 4 Aufrufstellen + (`_price_loop`, `_run_analysis`, `toggle_auto`, Snapshot-Keys `auto`/`min_conf`) + sowie die tote `auto`-Referenz in `TradingAgent.__init__` entfernt, `/api/auto` + gelöscht. `min_conf` (Frontend-Warnschwelle „Order ohne Signal") lebt jetzt als + eigene Konstante `_MIN_CONF_DISPLAY=70` in `engine.py`. Die `intended_trades`-DB- + Tabelle + `history.intended_*`-Methoden bleiben (historische Daten, kein Schreiber + mehr aktiv). Manuelles Handeln bleibt der validierte Edge auf dem Wave-Signal. +- **Auto-Squeeze-Entry = LIVE AUTONOM (User-Vorgabe 2026-07-16, `[trading] + auto_squeeze=true`, Default FALSE):** der Bot eröffnet **selbständig eine echte + Order**, sobald der **gemessen-validierte** Squeeze-Ausbruch feuert (`wave.squeeze. + state=='active'`) UND **flat** ist — NICHT auf dem Wave-Signal (das ist der + Dry-Run-Verlierer), sondern nur auf dem 2-Stichproben-positiven Squeeze. + `engine._check_auto_squeeze` im `_pos_loop` (1 s): **1× je Ausbruch** (Dedup über + (dir, level); re-armt, wenn der Squeeze klärt), öffnet via `open_long/short` (→ + Trailing an, Emergency-Stop armiert 2 % beim nächsten Tick), Log `🚀 AUTO-SQUEEZE- + ENTRY` + Telegram + Fehlschlag-Push. Snapshot `auto_squeeze`/`squeeze_entry_count`; + Toggle `POST /api/autosqueeze` (`set_auto_squeeze`) = Dashboard-Button **🚀 BRK** + (blau=AN, ersetzt den alten Dry-Run-AUTO-Button). **Dead-Hour-Guard = AUS + (User-Vorgabe 2026-07-17, `[trading] auto_squeeze_skip_night=false`):** der Bot + eröffnet jetzt **auch nachts 0–7 Uhr** auf Squeeze-Ausbrüche. Der ursprüngliche + Guard (`_SQUEEZE_NIGHT=(0..7)`, `_squeeze_skip_night`) bleibt **dormant/reaktivierbar** + (true = wieder an). Warum raus: das live-Verlust-Argument (3 Nacht-Squeezes −46 €) + 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×ATR, beide + Halbjahre negativ) bleibt aber gültig → **Nacht-Squeezes sind unbelegt, per B4- + Wochenmonitor beobachten**; tragen sie nicht, Guard wieder `=true`. ⚠ Erste echte + Autonomie, jetzt rund um die Uhr — B4-Monitor gegen +0,14…+0,23; driftet es → Button + aus / `auto_squeeze=false` / Guard zurück. + **Stop-&-Reverse = AUS (Default, `[trading] auto_squeeze_reverse=false`, + `_squeeze_reverse`; User-Vorgabe 2026-07-17):** War kurz scharf, dann abgeschaltet — + nur der **flat-Entry ist gemessen-validiert**, der Reverse ist unbelegt, verdoppelt + Kosten und dreht am **Ausbruch-Extrem** (real 2026-07-17 18:55: WAVE_REV_SHORT gegen + einen News-Rally-LONG-Ausbruch bei −29,64 € geschlossen, dann LONG am Erschöpfungs-Top + 82,32 — Elliott „impulse_up/5 exhausted" im selben Moment). Bei `false` bleibt ein + Gegen-Trade **unangetastet** (Drehen = manuelle Entscheidung); `_squeeze_last_sig` + wird dann NICHT gesetzt → schließt der Gegen-Trade (SL/manuell), kann der flat-Entry + auf denselben Ausbruch noch feuern. Bei `true` (historisch, reaktivierbar): läuft ein + Trade GEGEN den Ausbruch (auch manueller/Fremd-Trade!) → sofort `close(reason= + "squeeze_reverse")` (Log 🔄 + Telegram, Dedup `_squeeze_reverse_sig`), nächster flat-Tick + dreht. ⚠ NICHT gemessen (der Squeeze-Backtest nahm flat-Entry an) — deshalb Default aus. + **Exit-only-Variante („Gegen-Position beim Ausbruch schließen, OHNE Re-Entry") = + VERWORFEN (gemessen `backtest_squeeze_exit.py`, 2026-07-22, User-Idee „Breakout + schneller schließen"):** die sicherere Halb-Variante (nur schließen, nicht drehen) + fällt ebenfalls durch — in BEIDEN Hälften schlechter als der Live-Exit (SL 2×ATR+Trailing): + ΔΣR H1 −15 (immer)/−5 (nur im Minus), H2 −27/−29. Grund: der Gegen-Ausbruch ist oft ein + Fehlausbruch/Whipsaw → die Position prallt zurück, der frühe Close kappt Trades, die + sich erholt hätten. Der 07:38-Fall (LONG lief zum SL während SHORT-Ausbruch) war Varianz. + **Fazit: Squeeze taugt als ENTRY (validiert), NICHT als Gegen-EXIT.** Der einzige + Speed-Hebel bleibt die schnellere Erkennung (Variante A, ~5 s). + **S/R-Close-Cooldown (2026-07-17, Koordinations-Fix, kein Backtest):** nach einem + S/R-Auto-Close eröffnet der Auto-Squeeze **~3 min (`_SQUEEZE_SR_COOLDOWN_S=180`) + nicht in GLEICHER Richtung** wieder. Grund: der S/R-Close ging von „Level hält" + (P(break) + Balance-% > Fixwert):** + (a) **NEU — % der Einsatz-Margin dieser Position (aktiv, User-Vorgabe 2026-07-23):** + `[trading] auto_emergency_margin_pct=3.0` → Schwelle = **pct% × Margin** (dieselbe + Margin wie beim Gewinn-Close, aus `trader.refresh()`). **Retry-sicher** (Fix + 2026-07-23, gleiches Muster wie beim Gewinn-Close): läuft NICHT nur einmalig beim + Ticket-Wechsel, sondern jeden `_pos_loop`-Tick erneut, bis `order_calc_margin` + einmal >0 liefert (`_emergency_margin_armed_ticket`-Retry-Schutz) — verhindert, + dass bei einem Broker-Race im ersten Tick nach dem Öffnen dauerhaft ein stalen + Wert der vorherigen Position hängen bleibt. Mit 4 synthetischen Szenarien + verifiziert (sofort verfügbare Margin · verzögerte Margin/Retry · manuelles + Überschreiben bleibt erhalten · Neustart überschreibt NICHT). Snapshot + `emergency_margin_pct`. + (b) **% des Kontos (Fallback, wenn margin-%=0):** `[trading] auto_emergency_pct=2.0` + → bei jeder neuen Position wird die Schwelle auf **pct% × Balance** gesetzt + (`_emergency_pct`, skaliert mit dem Konto; z. B. 2 % von 831 € = −16,62 € ≈ + 0,4×ATR; 1× auf 1 % getestet, war zu eng → 2 %). UI-Feld zeigt im Flat-Zustand + den dynamischen Wert (Frontend rechnet `pct%×balance`, Snapshot `emergency_pct`). + (c) **Fixwert (Fallback, wenn beide %-Modi aus):** Schwelle = **zuletzt + genutzter Wert** (`_emergency_remember`, Startwert `[trading] auto_emergency_loss` + Default **10**, 0 = aus). + Setzt der User im UI einen neuen Wert, wird der gemerkt und ab dem nächsten Trade + übernommen. Aktiver Stop verfällt nach Auslösen UND bei Positions-Close, der + gemerkte Wert bleibt. **Neustart-fest (Fix 2026-07-17, `emergency_state.json`):** + Der aktive Notfall-Stop/TP wird mit dem **Ticket** persistiert (`_save/_load_emergency_ + state`, atomarer Write; bei Auto-Arm UND manuellem Set). Erkennt der Server dieselbe + offene Position nach einem `restart_server.bat` wieder, **stellt er den persistierten + Wert wieder her** (`_check_auto_close`: `ticket == _emergency_state_ticket` → restore, + Log „Auto-Close wiederhergestellt") statt auf die pct-Automatik zurückzuarmieren. Grund: + real am 2026-07-17 14:30 setzte ein Neustart bei offenem Trade die von Hand gesetzten + −25 € auf −19,42 € (2 %) zurück → Notfall-Close bei −19,64 statt bei −25. Eine echt + neue Position (anderes Ticket) armiert normal aus pct/Merkwert und persistiert neu. + **„aus"-Button schaltet auch den Auto-Arm ab** (setzt + `_emergency_remember=0` — vorher blieb der gemerkte Wert und re-armte beim nächsten + Trade, „aus" wirkte nur bis dahin; Bug gefixt 2026-07-07). Gilt genauso fürs Gewinn-Ziel. (Auto-Arm 1× auf manuell-only gestellt, nach Fehl-Trade + wieder reaktiviert — echter Schutz gegen falsche Empfehlungen; Signal-Filter dagegen + 4× gemessen wirkungslos/schädlich, s. `backtest_rev_htf.py`.) `engine.set_emergency()` · `POST /api/emergency {value}` · + `snapshot.emergency_loss`. Das ist KEIN automatischer Circuit-Breaker, sondern + ein manueller Hard-Stop (vom User gewünscht). **Benachrichtigung IMMER** (Ausnahme + vom „Close-Push nur im Plus"): bei Erfolg „🚨 Notfall-Close", bei **Fehlschlag** + „⚠ Notfall-Close FEHLGESCHLAGEN — manuell schließen!" (Schwelle bleibt dann aktiv, + nächster Tick versucht erneut). Eigener `send_telegram`, **nicht** vom + `pnl>0`-Gate (`history`) noch vom 5-Min-Cooldown betroffen. + +## Mobile-UI (web/) & Alarme +- Aufbau: **Tab-Navigation ganz oben** (sticky: Dashboard/Logs/Statistik/**Charts**/News), + darunter Header. +- **Design-System (`style.css`, Pass 2026-07-15, KEIN Framework — Tailwind bewusst + verworfen wegen WireGuard/Offline + kein Node):** Tokens in `:root` — Flächen-Leiter + `--bg < --surface-2 < --card`, `--border`/`--line`, Text `--dim`/`--bright` + (+`--text-*`-Aliasse, waren undefiniert = Bug gefixt), Radien `--r-sm/-r/-r-lg`, + `--shadow(-sm)`, `--mono`. **Font:** UI = System-Sans + `tabular-nums`; Zahlen-Werte + (Kurs/PnL/Felder/Logs) = `--mono`. Karten mit Elevation-Schatten, Fokus-Ringe auf + Feldern, Transitions auf Tabs/Buttons. Neutrale Hex-Literale → Tokens (Konsistenz). +- **Trade-Leiste** (`#trade-bar`, über der Gesamtempfehlung, nur bei offener Position, + 2026-07-15): eine Zeile Richtung (▲LONG/▼SHORT) · Lots · Einsatz · **3 Eingabefelder + `.tb-field` (Label über Feld, rot=`.stop`/grün=`.gain`): SL · TP · Gewinn-Close + (`#srmin-input`)** (Notfall-Close-Feld **2026-07-17 entfernt** — Notfall-Stop ganz aus). + SL/TP: Enter/Verlassen → + `POST /api/sltp` → setzt am Broker **und schaltet Trailing ab** (`trail.deactivate`); + Vorbefüllung aus `position.sl/tp`, Broker-Ablehnung → Toast. Gewinn-Close + (2026-07-15 aus der Position-Kachel hierher verschoben, User-Vorgabe — gleiches Design) + behält ID/Handler (`/api/srclose_min`). **Auch der + Auto-Close-Schalter** (`#srclose-btn`, jetzt `.tb-btn`-Box, AN/AUS grün/grau) sitzt in + der Zeile. Titel „aktueller Trade läuft"; Richtung/Lots/Einsatz = Anzeige-Boxen + (`.tb-val`), Rest editierbar — alle im selben `.tb-field`-Look. Position-Kachel hält + nur noch `cur-near`-Warnung + `pos-srhint`. (`.emg*`/`.sr-toggle`-CSS entfernt.) +- **Charts-Tab** (`#view-charts`): **M1/M5/M15/M30/H1 untereinander**, je ein + Candlestick-Chart mit **EMA12 (blau) + EMA50 (amber)**-Overlay und Trend-Header + (▲/▼/→ nach EMA12-vs-EMA50 mit Totband). Framework **TradingView Lightweight + Charts v4.2.3 — lokal vendored** (`web/lightweight-charts.standalone.production.js`, + KEIN CDN wegen WireGuard/Offline). Daten via `GET /api/bars?tf=&n=` → + `engine.get_bars` (OHLC+EMA+Trend unter `mt5_lock`). `app.js`: `loadCharts`/ + `renderChart`, Charts nur bei sichtbarem Tab geladen + Auto-Refresh 20 s + (schont den Lock). Broker-Zeit (UTC+3) → UTC via `broker_utc_offset`. + **Regressionskanal-Overlay (nur M30, 2026-07-20):** `get_bars` liefert bei M30 + `channel {dir, mid[], upper[], lower[]}` (Arrays deckungsgleich mit den Bars, + `structure.channel_series` — Regression über 60 abgeschl. Bars, linear über die + volle Breite extrapoliert). Frontend zeichnet 3 LineSeries (oben/unten gestrichelt, + Mitte gepunktet, grau). Dieselbe Kanal-Definition wie die Marktstruktur-Kachel — + REINE Anzeige (Signalvariante gemessen verworfen, s. `backtest_structure.py`). + **S/R-Overlay & Dashboard-Kachel = EINE Quelle** (`_draw_levels`; die Position-Kachel + `sr-res`/`sr-sup` speist jetzt via `_dash_sr` daraus statt aus dem Misch-Set + `_sr_levels` — vorher wichen Chart und Kachel ab, User-Feedback 2026-07-15). `_sr_levels` + bleibt intern für Strategie/Konfidenz. **„Klebrige" Hysterese** in `_draw_levels` + (`_draw_last`, verschärft 2026-07-21): die gezeichnete Linie **bleibt stehen, bis der + Kurs sie WIRKLICH durchbricht** (bid > R+0,1×ATR bzw. bid < S−0,1×ATR) — ein neuer, + näherer M5-Pivot löst KEINEN Wechsel mehr aus (das war die Zitter-Quelle: auf M5 + bilden sich laufend neue Pivots, die alte 0,25×ATR-Regel sprang darauf an). Ausnahme: + ist die alte Linie >1,5×ATR weit weg UND ein deutlich näheres Level existiert, wird + refresht (sonst bliebe sie irrelevant hängen). Gilt für PWA-Chart UND MQL5-CSV (eine + Quelle). + **Level-Auswahl nach Kursposition statt Entstehungstyp (Fix 2026-07-23, User-Frage + „Widerstand/Support-Retest von der falschen Seite"):** vorher kam Widerstand NUR aus + `ph` (Swing-Hochs) und Support NUR aus `pl` (Swing-Tiefs) — ein gebrochenes Level blieb + für IMMER im falschen Topf hängen. 2 von 4 Retest-Varianten hatten dadurch NIE eine + Durchbruchwahrscheinlichkeit: (1) Widerstand von unten ✓ funktionierte, (2) **Support- + Retest von unten** (Kurs unter einem gebrochenen Support, nähert sich von unten wieder) + ✗ kein Hinweis, (3) Support von oben ✓ funktionierte, (4) **Widerstand-Retest von oben** + (Kurs über einem gebrochenen Widerstand, nähert sich von oben wieder) ✗ kein Hinweis. + Fix: `ph`/`pl` bleiben GETRENNT geclustert (unverändert granular — ein erster Versuch, + beide Rohpools VOR dem Clustern zusammenzulegen, erzeugte in dichten Zonen Ketten-Mega- + Cluster durch die verdoppelte Punktdichte, >1×ATR breit, verworfen), aber NACH dem + Clustern werden beide fertigen Linien-Listen zusammengeführt und die Wahl erfolgt rein + per `min/max` relativ zu `bid` — nicht mehr nach Topf-Zugehörigkeit. Verifiziert: Fall 1 + bei Live-Daten (bid 90,951) und ein dichter Kontrollfall liefern exakt dieselben Level + wie vorher (keine Regression); Fälle 2+4 (synthetische Retest-Szenarien) finden jetzt + das nahe, vorher übersehene Level statt eines weit entfernten Ersatzlevels. Betrifft + `_draw_levels` direkt und damit ALLE Konsumenten (PWA-Chart, MQL5-CSV, Dashboard- + Kachel, `_sr_close_hint`/Auto-Close) — eine Quelle, ein Fix. + **S/R-Overlay:** `get_bars` liefert `levels {res, sup}` aus **`engine._draw_levels`** + = echte Kursstruktur (rohe M5-Pivots aus `wave.pb_levels`, geclustert ~0,5×ATR, + **nur die NÄCHSTE je Seite = 2 Linien total, deckungsgleich mit Dashboard + sr-res/sr-sup** — mehr Linien tauchten beim Kurslauf laufend auf/ab, User-Feedback + 2026-07-14; identisch zur Auto-Close-Logik). ⚠ Bugfix 2026-07-14: vorher + kamen die Linien aus `_sr_levels` (stale [zones]-Config + Tageslevel + Gaps) — + gezeichnet wurden Uralt-Level (81,24/82,64) statt aktueller Struktur. Farben + **blau** (User-Wunsch): Widerstand #1f6feb, Support #58a6ff. **Beschriftung** + (2026-07-14): PWA-Achsenlabel „Widerstand"/„Unterstützung" (+Nummer ab 2); + MQL5 zusätzlich sichtbares OBJ_TEXT-Label je Linie („Widerstand 78.65", + ~8 Kerzen vor dem rechten Rand, in Linienfarbe). + ⚠ **Kein MT5-Terminal-Zeichnen aus Python:** das `MetaTrader5`-Paket hat KEINE + `object_create`/`OBJ_HLINE`/`chart_*`-Funktionen (nur MQL5). S/R-Linien daher im + PWA-Chart. **Für das Desktop-Terminal: Datei-Bridge** — `engine._write_levels_file` + (im `_run_analysis`, ~5 s, `[trading] export_mql5_levels=true`) schreibt atomar + `\MQL5\Files\sr_levels.csv` (`SYM;…` / `R;preis` / `S;preis` / **`CH;; + t1;p1;t2;p2`** = Regressionskanal-Anker; Quelle **`_draw_levels`** (S/R) + + **`structure.channel_line`** (Kanal), Indikator-Farben blau DodgerBlue/DeepSkyBlue); + der Indikator **`mql5/SR_Levels.mq5`** (v1.10, in `MQL5\Indicators\`, **braucht + `OnCalculate` als No-op** sonst Compile-Error, F7-kompilieren, auf den Chart ziehen) + liest die S/R-Linien per Timer (OBJ_HLINE) UND den **M30-Regressionskanal als 3 + diagonale Trendlinien** (OBJ_TREND, nach rechts verlängert, oben/unten gestrichelt/ + Mitte gepunktet grau; `InpShowChannel`/`InpChanColor`, Default an) — Anker in + Broker-Zeit (= Server-Zeit im Terminal), zeichnet **nur bei Datei-Änderung neu** + (Inhalts-Cache `g_last` gegen Flackern). Der Kanal ist dieselbe M30-Definition wie + PWA-Overlay + Marktstruktur-Kachel; ⚠ nach dem Update den Indikator **neu F7- + kompilieren** (v1.10). Die dynamische Handels-Range (`DrawRange`, Hoch/Tief + der letzten `InpRangeBars` Kerzen) ist **per Default AUS** (`InpShowRange=false`, + User-Wunsch 2026-07-14) — bei Bedarf über die Indikator-Eingaben aktivierbar. (Die + statischen `[zones]`-Config werden NICHT mehr in die CSV geschrieben — waren stale + WTI-Level, füllten den Chart.) + **Architektur bewusst: Python = Gehirn (Analyse/Strategie/PWA/News/LLM/Reports), + MQL5 = nur dünne Zeichen-Schicht.** Kein Full-Rewrite in MQL5 (PWA/Backtests/ + Integrationen wären dort nicht/kaum machbar). **Sym-Zeile:** Symbol links · rechts `hdr-clock` + (**Srv = Broker-Wallclock** aus `market.server_time` via `getUTC*`, **Lok** = + Browser) + **Session** (`#session`; zeigt 21–22 Uhr lokal „🕘 PRIME-TIME" — + gemessen die stärksten Stunden — und bei Headline-Burst „📰 NEWS-STURM"/„viele + News" aus `news.storm_state()` → Snapshot `news_storm` {level: normal/elevated/ + storm, fresh, base_per_h}; Headline-Rate letzte Stunde vs Basisrate, Basis-Fenster + min. 6 h verankert. Beides reine Anzeige) + Verbindungspunkt. Darunter Balance · + **Tag P/L** · Equity. **Kein** Bid/Ask/Spread (Kurs steht in der Position-Kachel, + färbt sich nahe S/R). **Tag P/L** (`day-pl`) = realisiert heute + (`stats_overview("today")`) + offene Position; grün/rot + (`day_pl{realized,open,total}` im Snapshot). +- **Gesamtempfehlung** (`#card-verdict`, ganz oben): grafische Aggregation aller + Module — Konfidenz-Ring (conic-gradient = `conf_pct`), Bias-Balken mit Nadel + (SHORT↔LONG) und Modul-Chips (Welle/M30/H1/KI/Elliott/**Squeeze**, je grün/rot/grau; **TU + entfernt**). ⚠ **Bias-Fix 2026-07-15:** stimmberechtigt nur Module MIT Aussage — + Squeeze (nur `active`), Elliott (nur mit Ziel), KI (nur mit Bias) haben sonst + **Gewicht 0** (vorher dämpfte das fast immer neutrale Squeeze-w=2,0 die Nadel + permanent ~×0,8); `agree/total` zählt nur noch die ANDEREN stimmberechtigten + Module (Welle zählte sich selbst mit). Formel/Details: `docs/gesamtempfehlung.md`. + Headline = **validierte Welle** (steuert Order) + **Zeitebene** (`verdict.tf`, + z. B. „▲ LONG · M5" — TF, für die die Empfehlung gilt); Rest = nur Konsens-Anzeige. + Backend `engine._verdict` → `verdict` im Snapshot; Render `renderVerdict` + (`app.js`). Details: `docs/gesamtempfehlung.md`. +- **Karten** (Dashboard, von oben): Gesamtempfehlung · **Meldungen** · Marktstruktur · + KI-Copilot · Letzte Trades · Kurslücken · **Statistik (live) ganz unten**. + (Tagesziele-Kachel **entfernt** 2026-07-13. **Welle-Karte komplett entfernt + 2026-07-15** — TF-Ampel wanderte in die Gesamtempfehlung, die Wellen-Meldungen + [Squeeze/Überdehnung/Setup/Lauf/Gründe] in die **„Meldungen"-Karte** (`#card-pos`, + ex-„Position"); das Wave-Signal ist eh die Verdict-Headline. **Trailing-Karte + ausgeblendet 2026-07-22** — `#card-trail.hidden` per CSS, User-Vorgabe; Backend/ + Trailing-Logik + Snapshot-Felder `trail.*` unverändert aktiv, nur die Anzeige raus, + wieder einblendbar durch Entfernen der `hidden`-Klasse in `index.html`.) +- **Auto-Squeeze-Monitor (B4)** (`#sqm-box`, oben im **Statistik-Tab**, 2026-07-20): + zwei Blöcke — **Entry-Edge · Mechanik** (`engine.squeeze_monitor` rechnet die Squeeze- + Regel mit Live-Params auf ~30 Tagen M5 NEU, Backtest-Exit → **isoliert den Einstieg**, + ØR/PF gegen Erwartung **+0,14…+0,23 · PF>1**) und **Live-Trades · echt** (SQUEEZE_*- + Trades aus der DB, Gesamtsystem inkl. realer Exits: n/WR/Ø-Gew/-Verl/Verhältnis/ΣEUR/ + Tag-Nacht; ØR-Näherung via SL-Distanz). Verdict-Badge (trägt/dünn/**driftet→B5**) aus + der Mechanik. **Kernaussage der Ansicht:** klaffen Mechanik (Edge lebt) und Live + (echtes Geld) auseinander → es liegt am **Exit/Verhalten, nicht am Signal** (real + 2026-07-20: Mechanik ØR **+0,414**/PF 1,64, Live ØR −0,07/−2,41 € — der alte Notfall- + Stop + Nacht-Trades fraßen den Edge, jetzt behoben → sollte konvergieren). Endpoint + `/api/squeeze_monitor` (Cache 60 s), lädt beim Öffnen des Statistik-Tabs (`loadStats`). +- **Statistik-live-Kachel** (`#card-stats-live`): kompakt Woche + Gesamt — + Trades, WR, PF, Ø-Gewinn/-Verlust, **Verhältnis** (Øgew/Øverl, <1 = Verlust- + Schiefe), PnL. Quelle `engine._compact_stats` → `snapshot.stats_compact`. Zum + objektiven Verfolgen der Stichprobe (statt Bauchgefühl pro Einzeltrade). + **TradersUnion- und Elliott/FVG- + Einzelkacheln ausgeblendet.** Elliott/FVG fließt weiter in die Gesamtempfehlung + (`_verdict`); **TU ist ganz raus aus Empfehlung, Verdict UND KI-Copilot-Kontext** + (`agent._tool_market` ohne `tradersunion`/`tu_consensus` — war die letzte Hintertür, + da der Copilot eine Verdict-Stimme ist; nur noch Snapshot-Anzeige). +- **TF-Wende-Ampel** (`#wave-turns`, jetzt IN der Gesamtempfehlung): Chips + **M1/M5/M15/M30/H1** (H1 neu 2026-07-15 — im `turn_data`-Fetch, aber NICHT im + Bounce-Scan, der bleibt M1–M30), je grün(auf)/rot(ab) nach **EMA12/50-Stand**; + **frische Kreuzung (≤3 Bars)** blinkt + zeigt ⟳ + Richtungspfeil + Bars-seit-Wende. + Quelle: `wave.tf_turns` + (Backend `WaveRecommender._turn_state` mit **Totband** `_TREND_DEADBAND×ATR` → + kein Geblinke bei seitwärts laufenden EMAs). 4-TF-Fetch ist **gedrosselt (~15 s)** + via `_turn_due` (spart Lock-Zeit; letzter Stand in `_last_tf_turns` gehalten). + REINE Anzeige — ändert die Empfehlung NICHT. +- **Marktstruktur-Kachel** (`#card-structure`, 2026-07-20, User-Idee „Pro-Chart- + Setup-Erkennung"): zeigt die Price-Action-Struktur auf **M30** — Swing-Folge + **HH/HL/LH/LL** (Chips grün=bullisch/rot=bärisch), letzter **BOS** (Break of + Structure, Richtung + gebrochenes Level + Bars her) und den **Regressionskanal** + (Richtung + Position des Kurses im Kanal 0–100 % als Balken+Punkt). Quelle + `core/structure.py` (`MarketStructure`, Pivots k=3, Kanal = LS-Regression über 60 + Bars ± max. Abweichung, ~30 s gedrosselt im Trend-Loop unter `mt5_lock`), Snapshot + `structure`. **REINE ANZEIGE — kein Trade-Trigger, kein Verdict-Gewicht, KEINE + Prognose/Pfeil.** **Signalvariante DIREKT gemessen & VERWORFEN (`backtest_structure.py`, + 2026-07-20, 60k M30-Bars/5 J, 2 Halbjahre, dieselbe structure.py-Logik kausal):** + ALLE Varianten in BEIDEN Hälften negativ — (A) Kanal-Pullback „Kauf am unteren + Kanaldrittel/Demand-Zone" = der orange Pfeil: ØR −0,05…−0,11, PF<1, WR 40–43 % + (der Kanalrand HÄLT nicht zuverlässig — antizyklisch am Extrem = Münzwurf, P(break)- + Klasse); (A2) +Swing-Filter (HH/HL) macht es nicht besser (−0,06/−0,07); (B) BOS- + Continuation = breakeven-negativ (−0,011/−0,005, Momentum-Klasse). Damit ist auch + die Struktur-Idee empirisch durch — die Kachel bleibt Ist-Zustand-Anzeige (wie + TF-Ampel/Squeeze), steuert nichts. +- **Chart-Muster (Doppeltop/-boden) als Signal = VERWORFEN (gemessen `backtest_doubletop.py`, + 2026-07-20, User-Idee „Mustererkennung SKS/Doppeltop"):** das am saubersten mechanisch + definierbare Umkehrmuster (2 ~gleiche Swing-Pivots + Nackenlinien-Bruch, aus derselben + Pivot-Erkennung; Einstieg beim Bruch, Live-Exit, 60k M30-Bars, 2 Halbjahre). ALLE + Toleranzen (0,3/0,6/1,0×ATR) fallen durch: WR nur 40–45 %, ØR −0,01…−0,15, PF<1; + die einzige leicht positive Zelle (H1 tol 0,3 +0,044) kippt in H2 auf −0,150 = Rauschen/ + Regime, kein Edge. Deckt sich mit der schwachen Empirie (Lo/Mamaysky/Wang 2000: Effekt + klein & kaum handelbar; NY Fed: SKS nicht profitabel; out-of-sample schwach) UND der + Struktur-Klasse (Muster = Swings+Trendlinien = die gerade verworfene Klasse). **11. + verworfener Signal-Eingriff — am Signal ist nichts zu holen.** +- **Kurslücken-Kachel** (`#card-gaps`): offene D1-Gaps des laufenden Jahres + (`gaps.GapAnalyzer`, Refresh ~10 min im Trend-Loop unter `mt5_lock`). Je Gap: + Richtung, Zone, Fill-Level, Abstand zum Kurs. Die **Fill-Level fließen als + S/R-Magnete in `_sr_levels`** → damit in die Wellen-Konfidenz (`set_sr_context`, + `_SR_BONUS`/`_SR_PENALTY`), Close-Alarme und Verdict-Kontext — und so indirekt in + den **Autotrader** (der auf die Welle hört). **Keine** eigene Handelsrichtung. + Snapshot `gaps`. Mess-/Einzeltool: `find_gaps.py`. + - **Gap-Magnet als Handelssignal = VERWORFEN (gemessen, `backtest_gaps.py`):** + Signale WEG vom nächsten offenen Gap haben **+0,094** Ø-Edge, ZUM Gap nur + **+0,014** — der Magnet zieht NICHT (das Gap blieb offen, weil der Trend von + ihm weglief). „Richtung Gap handeln" wäre schädlich; „weg" ist nur + Trendbestätigung (macht die Welle schon). Gaps bleiben reiner Kontext. +- **Tagesziele — Dashboard-Kachel ENTFERNT (2026-07-13):** Das Frontend zeigt die + Web-Prognose nicht mehr. Das Modul `daily_levels` läuft **unverändert weiter**: die + Level fließen als **S/R-Kandidaten** in `_sr_levels` (Anzeige/Close/Verdict-Kontext) + und in den 07:30-Tagesreport. `snapshot.daily_levels` bleibt befüllt (nur ohne UI). +- **Täglicher Report** (07:30 Berlin, `engine._send_daily_report`): Web-Prognose + + **Vortags-Bilanz** (`stats_overview("yesterday")`) → **Telegram-Push** + **E-Mail** + (via `mailer.send_graph_mail`, `[graph]`-Creds, Absender `[graph] sender`= + mailagent@hocks.eu). Empfänger: `[graph] report_to` (kommagetrennt, Default + `trading@hocks.eu, axel@hocks.eu`). Ersetzt `scripts/send_daily_report.ps1` (das + hier nie lief — Microsoft.Graph-PS-Modul fehlt; Python nutzt Graph-REST direkt). + ⚠ Graph/Exchange-Zustellung kann **mehrere Minuten verzögert** sein (202 = nur + angenommen). Fail-safe: kein Provider/Graph → Report übersprungen. **Versand-Status + persistiert** (`report_state.json`, `_load/_save_report_state`) — vorher schickte + jeder Server-Neustart nach 07:30 den Report ERNEUT (real 3× am 07.07.); gilt für + Tages- UND Wochenreport. +- **B4-Wochenreport (automatisch, `engine._send_weekly_report`):** **montags ab 07:30 + Berlin, 1×/Woche** (gleicher `_daily_loop`, `_weekly_last_run`=(ISO-Jahr,KW)) → + **E-Mail + Telegram**. Ruft `weekly_review.build_report(m5=…)` (Live-Stats vs. + Backtest-Erwartung, Leckage-Flags — Track B/B4) und schickt es als `
` (Betreff
+  „Oil Wochenreport (B4) — KW…", Empfänger = `[graph] report_to`). **Squeeze-Breakout-
+  B4-Monitor** (`_squeeze_section`): rechnet die Squeeze-Regel mit den Live-Params auf
+  den letzten ~30 Tagen M5 NEU (mechanischer Setup → Live-Perf. = Neuberechnung, keine
+  Trade-Attribution nötig) und hält ØR/PF gegen +0,14…+0,23. M5-Bars holt die Engine
+  unter `mt5_lock` und reicht sie durch (`build_report(m5=…)`; standalone lädt via
+  `_fetch_m5`, schließt MT5 nur bei Eigen-Init). Fail-safe wie der Tagesreport.
+  Manuell jederzeit: `python weekly_review.py`.
+- **Position-Kachel zeigt SL/TP + Einsatz** (`pos-sl`/`pos-margin`;
+  `trader.snapshot()` liefert `sl`/`tp`/`margin`). **Kopfzeile der Kachel**
+  (`.pos-top`, 2026-07-15, User-Vorgabe): **Kurs · G/V** in EINER Zeile (18-px).
+  Das G/V (`#pnl`) zeigt **NUR NETTO** (Brutto entfernt, kein „n."-Präfix): bei
+  Gewinn P&L × (1 − `[trading] wht_pct`/100) = abzgl. WHT-Quellensteuer, bei Verlust
+  unverändert (keine Steuer). Snapshot `wht_pct`; Tooltip nennt den Satz. Swap ist
+  bereits im P&L (`trader.pnl = profit + swap`), Kommission beim Standard-Konto 0.
+  Einsatz/SL/TP/Richtung stehen in der Trade-Leiste, S/R im Chart (aus der Kachel
+  entfernt); `emg-lbl`-Beschriftungen ausgeblendet (Felder self-labeln via
+  Platzhalter/Button-Text). Fehlender SL → rot „⚠ kein SL
+  gesetzt!". „Einsatz" = gebundene Margin (`order_calc_margin`). **Notfall-Stop-
+  Feld** (`#emg-input` + setzen/aus): manueller Verlust-Wert → Server schließt
+  sofort (s. „Notfall-Stop"). Feld wird mit aktivem bzw. gemerktem Wert
+  (`emergency_loss`/`emergency_remember`) **vorbefüllt** (außer beim Tippen);
+  **Enter/„Go"** löst „setzen" wie der Button aus. Status `#emg-state`: „aktiv:
+  schließt bei −…" (rot) bzw. „vorgemerkt −… (aktiv beim Öffnen)". **Gewinn-Feld
+  („Gewinn mitnehmen bei") aus dem Dashboard ENTFERNT (User-Vorgabe 2026-07-14)** —
+  das Backend bleibt vollständig erhalten (`engine.set_takeprofit`, kombinierter
+  `_check_auto_close`, `POST /api/takeprofit`, Telegram „💰 Gewinn mitgenommen",
+  Config `[trading] auto_takeprofit` Default 0=aus), ist ohne UI aber schlafend;
+  der P(break)-S/R-Auto-Close übernimmt die Gewinnmitnahme-Rolle.
+- **Blinken:** LONG/SHORT-Button blinkt bei jeder Richtungs-Empfehlung (nicht mehr
+  an 70 % gekoppelt). **Ton bei Empfehlung** (`beepRecommendation`): **sofort beim
+  Signal-Wechsel** (WARTEN→LONG/SHORT oder Flip, 3-s-Flacker-Schutz) **+ Erinnerungs-
+  Ton alle 30 s** (`_REC_REBEEP_MS`), solange der Button blinkt — **nur wenn flat**
+  (keine Position). LONG = aufsteigend, SHORT = absteigend (unterscheidbar vom
+  Close-Alarm). Audio erst nach 1× Nutzer-Tap aktiv — ist es noch gesperrt, zeigt
+  ein einmaliger Toast „🔇 Ton gesperrt — einmal tippen". Kein Ton bei offener
+  Position. **Mute-Button** (`#mute-btn`, 🔊/🔇 rechts in der Tab-Leiste) schaltet
+  ALLE Töne (Gate in `_tone` + beiden beep-Funktionen), persistiert via
+  `localStorage("oil_muted")`. **CLOSE blinkt + Ton NUR wenn `pnl > sr_close_min_gain`**
+  (Feld leer/0 → `pnl>0` wie bisher; deckt sich mit dem Auto-Close-Gate — unter der
+  Schwelle schließt der Bot ohnehin nicht, also auch kein Alarm; Frontend `gainOK`,
+  gilt für Flip-Close UND S/R-Close-Blink/Ton). Der Telegram-Flip-Push (`_check_close_
+  alert`, Backend) bleibt bei `pnl>0`. **Flip-Close** = Signal-Flip gegen die Position.
+  **S/R-Ziel-Close-Hinweis
+  ENTFERNT** (stupste zum verfrühten Gewinner-Kappen → Verlust-Schiefe; Gewinner
+  führt das Trailing). Bei Verlust-Trade: kein Close-Alarm. **Jeder echte Auslöser
+  loggt** `🔔 Flip-Close-Alarm #N` + Zähler `snapshot.close_alert_count` (zum
+  objektiven Nachzählen, statt aus Trades/Recs zu rekonstruieren). Engine: `_check_close_alert` (Telegram max.
+  **1×/5 min**, `_CLOSE_ALERT_COOLDOWN_S` — kein Spam, wenn der Kurs ums Level
+  pendelt). ⚠ Cooldown ist **ticket-basiert** (`_close_alert_ticket`): Reset NUR
+  bei echter neuer Position; ein kurz flackerndes Ticket (transient leeres
+  `positions_get`) setzt den Cooldown NICHT mehr zurück (war die Spam-Ursache).
+  Frontend: `flipClose || srTarget`. Kein Auto-Close an S/R (Backtest).
+- **Trade-Abschluss-Telegram ENTFERNT** (`history.log_trade_close` sendet kein
+  Telegram mehr; Import bereinigt). Close-bezogene Telegrams kommen jetzt **nur
+  noch** aus der Engine: **🔔 CLOSE-Signal** (echter Signal-Flip, `_check_close_
+  alert`) + **🚨 Notfall-Close / 💰 Gewinn-Close** (armierter Auto-Close,
+  `_check_auto_close`). Frontend-Blink/Ton weiterhin nur bei Flip im Plus.
+- **Order-Bestätigungen:** Kein hartes Blockieren mehr. Eröffnen bei WARTEN/Gegen-
+  Signal → **Hinweis-Dialog „trotzdem eröffnen?"** (überschreibbar). CLOSE bei
+  **negativem** P&L → Sicherheitsabfrage; im Plus/Breakeven direkt. Sonst keine
+  Token-Dialoge (`require_token=false`). Audio braucht 1× Nutzer-Tap.
+- Asset-Version aktuell **v=108** (in `web/index.html` hochzählen, siehe Workflows).
+
+## Web-API (server.py)
+`GET /api/snapshot` (inkl. `verdict`-Aggregat) `/api/health` `/api/logs` `/api/stats` `/api/news` `/api/bars?tf=&n=` (Charts, read-only) `/api/squeeze_monitor` (B4-Monitor, read-only, Engine-Cache 60 s) · `WS /ws` ·
+`POST /api/order|auto|trail|emergency|takeprofit|sltp|srclose|srclose_min|auth/check`.
+`/api/sltp {sl,tp}` = manuelles SL/TP der offenen Position → `engine.set_sltp` →
+`trader.modify_sltp` (TRADE_ACTION_SLTP, prüft Seite/Mindestabstand) **und deaktiviert
+das Trailing** (`trail.deactivate` — sonst zöge es SL/TP beim nächsten Tick zurück,
+User-Vorgabe). ⚠ **Extern-Erkennung (Fix 2026-07-17):** Setzt der User SL/TP **direkt
+im MT5-Terminal** (nicht über die App), greift `deactivate()` nicht — das Trailing
+überschrieb die Handeingabe (real: TP 90,000 → 81,484). `trailing._do_modify` vergleicht
+jetzt den Broker-SL/TP mit dem **zuletzt selbst gesetzten** (`_last_sl/_last_tp`, tol =
+Modify-Schwellwert): weicht er ab → **Trailing schaltet sich ab, Handeingabe bleibt
+stehen** (Log „Manuelle SL/TP-Änderung erkannt"). `_last_sl/_last_tp` werden bei
+Aktivierung genullt (kein Altwert-Fehlalarm); Erkennung erst ab dem 2. Modify. Token via `[web] api_token`; bei
+`require_token=false` ist die Token-Pflicht aus (Zugang nur via WireGuard).
+- **Snapshot-Cache:** `/api/snapshot` UND `/ws` liefern `engine.snapshot_cached()`
+  (TTL `_SNAP_TTL_S=0,8 s`) — EIN berechneter Snapshot wird über alle Geräte
+  geteilt, SQLite/Verdict-Last bleibt unabhängig von der Client-Anzahl.
+- **WS-Wakeup nach Sperrbildschirm/Hintergrund (Fix 2026-07-22, User-Meldung „G/V
+  großer Lag"):** Backend-Pipeline ist schnell (`_pos_loop` 1 s · Snapshot-Cache
+  0,8 s · WS-Push 1 s; gemessen: MT5-Lock-Last selbst im Worst-Case nur ~25 ms,
+  0 Lock-Timeouts/`_pos_loop`-Fehler im Log) — die Ursache lag im **Frontend**:
+  mobile Browser pausieren WS+Timer beim Sperren/Backgrounding, ohne
+  `visibilitychange`-Handling zeigte die Seite bis zu einem zufälligen Reconnect
+  den STALEN Stand (Minuten alt). Fix: `visibilitychange`/`pageshow`/`focus`-Listener
+  (`_wakeRefresh` in `app.js`) holen beim Zurückkommen sofort einen frischen
+  REST-Snapshot UND bauen die WS-Verbindung hart neu auf (statt auf den evtl.
+  hängenden alten Socket zu warten).
+  **REST-Polling als Sicherheitsnetz (2026-07-22, User-Wunsch „JSON-Feed"):**
+  zusätzlich zum WS-Push pollt `app.js` alle `POLL_MS=4000` ms `/api/snapshot` —
+  fängt den Fall auf, dass der WS still hängt (kein `onclose`, keine Zustellung
+  mehr, z. B. schwaches WireGuard-Signal) OHNE auf einen Reconnect zu warten;
+  pausiert bei `document.hidden` (Akku/Daten). **`lastTs`-Dedup** (`_applySnapshot`,
+  vergleicht `engine.snapshot_cached().ts` — dieselbe Quelle für WS UND REST):
+  nur die jeweils NEUERE Quelle rendert → kein Flackern/Doppel-Render, wenn beide
+  fast gleichzeitig eintreffen. Alle Snapshot-Konsumenten (initialer Load,
+  Wake-Refresh, Poll, WS) laufen jetzt über `_applySnapshot` statt direkt `render`.
+  v=108. `GET /api/snapshot` ist ohnehin öffentlich/token-frei nutzbar — externe
+  Tools (Shortcuts, Home Assistant, …) können ihn direkt pollen.
+
+## Auch beachten
+- **Startup-Schonfrist gegen Sofort-Close nach Neustart (User-Vorgabe 2026-07-20,
+  `[trading] startup_close_grace_s=60`):** In den ersten 60 s nach Bot-Start schließt
+  der Bot **keinen laufenden Trade selbst** — gilt für **S/R-Close** (`_check_sr_close`),
+  **Notfall-Stop/Gewinn-Ziel** (`_check_auto_close`, Armieren läuft weiter, nur der Close
+  ist gesperrt), **Time-Stop** (`trailing`, via `set_no_close_until`) und **Auto-Squeeze-
+  Reverse**. Grund: nach `restart_server.bat` sind Preis/ATR/P(break)/Level für einen
+  Moment instabil → ein adoptierter Trade, der gerade in S/R-Close- oder Notfall-Nähe
+  liegt, würde sonst in der ersten Sekunde auf halb-initialisierten Daten gekappt.
+  Engine setzt `_no_autoclose_until = start + grace`, `_in_startup_grace()` gated die
+  drei `_pos_loop`-Checks; die Trailing-Instanz bekommt denselben Stichtag. ⚠ Der
+  **Broker-SL (2×ATR) bleibt unberührt** — er läuft am Broker, kann NICHT verzögert
+  werden (dort ist auch keine Verzögerung erwünscht: ein Stop-Loss muss greifen). 0 = aus.
+- **Update-Reboot-Lücke (Root-Cause der Nacht 2026-07-20, −95,48 €):** Windows
+  Update rebootete 01:30 (MoUsoCoreWorker/TrustedInstaller, 2×), der Server-Task
+  `OilTradingServer` hat nur einen **Logon-Trigger** → Server+MT5 down bis zum
+  Login 07:42; Time-Stop/S/R-Close/Notfall-Stop liefen nicht, nur der **Broker-SL**
+  (broker-seitig!) schützte. **Fix:** WU-Policy `AUOptions=4, Tag=Sa, 03:00`
+  (HKLM\...\WindowsUpdate\AU — Reboots nur noch Samstag früh, Markt zu); Standby/
+  Hibernate AC+DC=0. ⚠ Offen: nach einem Sa-Reboot startet der Server erst beim
+  nächsten Login — **Auto-Logon (netplwiz) wäre die letzte Lücke** (So-Nacht-Open),
+  braucht das Windows-Passwort des Users. Server-seitige Schutzschichten sind nur
+  so gut wie die PC-Laufzeit.
+- **UI-Einstellungen neustart-fest (`runtime_state.json`, 2026-07-19):** App-gesetzte
+  Werte — 🚀-BRK-Toggle (`auto_squeeze`), Auto-Close-Schalter (`auto_sr_close`),
+  Gewinn-Close-Mindestgewinn (`sr_close_min_gain`) — werden bei jedem `set_*` atomar
+  persistiert (`_load/_save_runtime_state`) und überschreiben beim Start die
+  ini-Defaults (nur vorhandene Keys). ⚠ **`set_sr_close_min_gain` vergaß den `_save`
+  bis 2026-07-22** (Bug, jetzt gefixt) — der am Handy gesetzte Mindestgewinn überlebte
+  Änderungen nicht sauber; zusätzlich schickte Enter auf leerem Feld ungewollt 0
+  (`change`+`keydown` doppelt → Enter blurrt jetzt nur, `srclose_min`-Endpoint loggt `[WEB]`).
+- **Log-Hygiene:** Trail-DEBUG-Zeile (`Trail: profit=…`) auf **1×/30 s gedrosselt**
+  (2026-07-19; vorher ~28k Zeilen je Rotation → 5×5 MB deckten nur ~2 Tage Forensik).
+- **DB-Guard:** `history.log_trade_open` verwirft 0-Lot-/Preis-lose Einträge
+  (Reconcile-Artefakte; 6 Alt-Zeilen am 2026-07-19 bereinigt, Backup
+  `oil_widget_history.db.bak-2026-07-19`).
+- Persönliches Auto-Memory unter `C:\Users\ah\.claude\projects\…\memory\`
+  (Beobachtungen/Feedback) ergänzt diese Datei.
+- `oil_widget_config.ini` enthält **Live-Secrets** (Keys/Token) — nicht ins
+  Repo/Logs leaken.
+- **DB** `oil_widget_history.db` (trades · intended_trades · recommendations · **candles_m1**).
+  Vor jedem Eingriff Backup (`*.bak-`); Zeitspalten sind lokale Epoch
+  (Broker-Offset bereits korrigiert) — **AUSNAHME `candles_m1.time` = ROHE Broker-Zeit
+  (UTC+3)**, deckungsgleich mit `copy_rates`.
+- **M1-Candle-Logger (`core/candle_logger.py`, 2026-07-21, Daten-Sammlung, kein
+  Strategie-Eingriff):** schreibt abgeschlossene M1-Kerzen (OHLC + Spread-in-Preis +
+  tick_volume + symbol) nach `candles_m1` (PK `time`, INSERT OR IGNORE = dedupliziert,
+  self-healing). `engine.candles.log(sym)` im Trend-Loop, self-throttled ~55 s; erster
+  Lauf backfillt ~3000 Bars, danach die letzten 180. **Warum:** Broker-M1-History rollt
+  weg (kurz); M1 ist die Master-Auflösung → aus M1 jede höhere TF exakt aggregierbar.
+  ⚠ Ticks/Echtzeit bewusst NICHT geloggt (~85k/Tag = 30M/Jahr, kein Bedarf — Strategien
+  sind M5+, Slippage aus echten Fills gemessen). ~370k Bars/Jahr = vernachlässigbar.
diff --git a/analyze_execution.py b/analyze_execution.py
new file mode 100644
index 0000000..5b64efb
--- /dev/null
+++ b/analyze_execution.py
@@ -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()
diff --git a/analyze_pbreak_flicker.py b/analyze_pbreak_flicker.py
new file mode 100644
index 0000000..53d4f67
--- /dev/null
+++ b/analyze_pbreak_flicker.py
@@ -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()
diff --git a/analyze_srwaves.py b/analyze_srwaves.py
new file mode 100644
index 0000000..51b7f38
--- /dev/null
+++ b/analyze_srwaves.py
@@ -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 ef0:
+            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=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])&(p27}{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()
diff --git a/analyze_today.py b/analyze_today.py
new file mode 100644
index 0000000..affe8e8
--- /dev/null
+++ b/analyze_today.py
@@ -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+K3}  {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']}")
diff --git a/backtest_adverse_nosignal.py b/backtest_adverse_nosignal.py
new file mode 100644
index 0000000..5b1267e
--- /dev/null
+++ b/backtest_adverse_nosignal.py
@@ -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| 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()
diff --git a/backtest_angle.py b/backtest_angle.py
new file mode 100644
index 0000000..e645328
--- /dev/null
+++ b/backtest_angle.py
@@ -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()
diff --git a/backtest_atrfloor.py b/backtest_atrfloor.py
new file mode 100644
index 0000000..c2020d2
--- /dev/null
+++ b/backtest_atrfloor.py
@@ -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=_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 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()
diff --git a/backtest_bounce_reliability.py b/backtest_bounce_reliability.py
new file mode 100644
index 0000000..d73cbcb
--- /dev/null
+++ b/backtest_bounce_reliability.py
@@ -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()
diff --git a/backtest_breakout.py b/backtest_breakout.py
new file mode 100644
index 0000000..c072a74
--- /dev/null
+++ b/backtest_breakout.py
@@ -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()
diff --git a/backtest_breakout_squeeze.py b/backtest_breakout_squeeze.py
new file mode 100644
index 0000000..2117001
--- /dev/null
+++ b/backtest_breakout_squeeze.py
@@ -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()
diff --git a/backtest_chopgate.py b/backtest_chopgate.py
new file mode 100644
index 0000000..4be5cc1
--- /dev/null
+++ b/backtest_chopgate.py
@@ -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
+(ER0 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 ef1 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()
diff --git a/backtest_conf.py b/backtest_conf.py
new file mode 100644
index 0000000..6f03d2d
--- /dev/null
+++ b/backtest_conf.py
@@ -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()
diff --git a/backtest_confluence.py b/backtest_confluence.py
new file mode 100644
index 0000000..62e4d1c
--- /dev/null
+++ b/backtest_confluence.py
@@ -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()
diff --git a/backtest_confluence_angle.py b/backtest_confluence_angle.py
new file mode 100644
index 0000000..7ab7c4b
--- /dev/null
+++ b/backtest_confluence_angle.py
@@ -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()
diff --git a/backtest_deadhour.py b/backtest_deadhour.py
new file mode 100644
index 0000000..941413e
--- /dev/null
+++ b/backtest_deadhour.py
@@ -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 ef1 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()
diff --git a/backtest_doubletop.py b/backtest_doubletop.py
new file mode 100644
index 0000000..2c1837f
--- /dev/null
+++ b/backtest_doubletop.py
@@ -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()
diff --git a/backtest_entryroom.py b/backtest_entryroom.py
new file mode 100644
index 0000000..893d78c
--- /dev/null
+++ b/backtest_entryroom.py
@@ -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()
diff --git a/backtest_events.py b/backtest_events.py
new file mode 100644
index 0000000..f17188b
--- /dev/null
+++ b/backtest_events.py
@@ -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=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()
diff --git a/backtest_exit2.py b/backtest_exit2.py
new file mode 100644
index 0000000..68f4859
--- /dev/null
+++ b/backtest_exit2.py
@@ -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()
diff --git a/backtest_flipclose.py b/backtest_flipclose.py
new file mode 100644
index 0000000..383fee8
--- /dev/null
+++ b/backtest_flipclose.py
@@ -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()
diff --git a/backtest_gaps.py b/backtest_gaps.py
new file mode 100644
index 0000000..116e00d
--- /dev/null
+++ b/backtest_gaps.py
@@ -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"]=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()
diff --git a/backtest_hourly.py b/backtest_hourly.py
new file mode 100644
index 0000000..c713c36
--- /dev/null
+++ b/backtest_hourly.py
@@ -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 ef3} {'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()
diff --git a/backtest_hourly_split.py b/backtest_hourly_split.py
new file mode 100644
index 0000000..b68bb84
--- /dev/null
+++ b/backtest_hourly_split.py
@@ -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 ef1 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()
diff --git a/backtest_htf_angle.py b/backtest_htf_angle.py
new file mode 100644
index 0000000..c9d0b72
--- /dev/null
+++ b/backtest_htf_angle.py
@@ -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()
diff --git a/backtest_htf_filter.py b/backtest_htf_filter.py
new file mode 100644
index 0000000..29878b2
--- /dev/null
+++ b/backtest_htf_filter.py
@@ -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()
diff --git a/backtest_improve.py b/backtest_improve.py
new file mode 100644
index 0000000..166ef15
--- /dev/null
+++ b/backtest_improve.py
@@ -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)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()
diff --git a/backtest_intermarket.py b/backtest_intermarket.py
new file mode 100644
index 0000000..97bcbd8
--- /dev/null
+++ b/backtest_intermarket.py
@@ -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()
diff --git a/backtest_momentum.py b/backtest_momentum.py
new file mode 100644
index 0000000..399eb11
--- /dev/null
+++ b/backtest_momentum.py
@@ -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()
diff --git a/backtest_orb.py b/backtest_orb.py
new file mode 100644
index 0000000..d6d4b92
--- /dev/null
+++ b/backtest_orb.py
@@ -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()
diff --git a/backtest_pullback.py b/backtest_pullback.py
new file mode 100644
index 0000000..577800b
--- /dev/null
+++ b/backtest_pullback.py
@@ -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()
diff --git a/backtest_range.py b/backtest_range.py
new file mode 100644
index 0000000..859b267
--- /dev/null
+++ b/backtest_range.py
@@ -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 pcur],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()
diff --git a/backtest_realcosts.py b/backtest_realcosts.py
new file mode 100644
index 0000000..ceb50ed
--- /dev/null
+++ b/backtest_realcosts.py
@@ -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 ef3} {'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()
diff --git a/backtest_rev_exit.py b/backtest_rev_exit.py
new file mode 100644
index 0000000..cb3ae78
--- /dev/null
+++ b/backtest_rev_exit.py
@@ -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()
diff --git a/backtest_rev_htf.py b/backtest_rev_htf.py
new file mode 100644
index 0000000..cc9618c
--- /dev/null
+++ b/backtest_rev_htf.py
@@ -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()
diff --git a/backtest_revconf.py b/backtest_revconf.py
new file mode 100644
index 0000000..c51df1f
--- /dev/null
+++ b/backtest_revconf.py
@@ -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()
diff --git a/backtest_reversal.py b/backtest_reversal.py
new file mode 100644
index 0000000..881fd4f
--- /dev/null
+++ b/backtest_reversal.py
@@ -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()
diff --git a/backtest_revlock.py b/backtest_revlock.py
new file mode 100644
index 0000000..4238741
--- /dev/null
+++ b/backtest_revlock.py
@@ -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()
diff --git a/backtest_short_h1filter.py b/backtest_short_h1filter.py
new file mode 100644
index 0000000..536ff49
--- /dev/null
+++ b/backtest_short_h1filter.py
@@ -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()
diff --git a/backtest_signal.py b/backtest_signal.py
new file mode 100644
index 0000000..8bff82e
--- /dev/null
+++ b/backtest_signal.py
@@ -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()
diff --git a/backtest_sizing.py b/backtest_sizing.py
new file mode 100644
index 0000000..bdf4871
--- /dev/null
+++ b/backtest_sizing.py
@@ -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 g9680%) = Quasi-Ruin-Risiko.")
+
+if __name__=="__main__":
+    main()
diff --git a/backtest_sl_method.py b/backtest_sl_method.py
new file mode 100644
index 0000000..5628221
--- /dev/null
+++ b/backtest_sl_method.py
@@ -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()
diff --git a/backtest_sl_tf_mismatch.py b/backtest_sl_tf_mismatch.py
new file mode 100644
index 0000000..9e1511b
--- /dev/null
+++ b/backtest_sl_tf_mismatch.py
@@ -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()
diff --git a/backtest_squeeze_exit.py b/backtest_squeeze_exit.py
new file mode 100644
index 0000000..04bf5ec
--- /dev/null
+++ b/backtest_squeeze_exit.py
@@ -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()
diff --git a/backtest_srbreak.py b/backtest_srbreak.py
new file mode 100644
index 0000000..df8732a
--- /dev/null
+++ b/backtest_srbreak.py
@@ -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=_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 ef0:
+                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 p0 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()
diff --git a/backtest_srclose.py b/backtest_srclose.py
new file mode 100644
index 0000000..718ce03
--- /dev/null
+++ b/backtest_srclose.py
@@ -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=_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 ef0 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=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()
diff --git a/backtest_srclose_prob.py b/backtest_srclose_prob.py
new file mode 100644
index 0000000..28324c1
--- /dev/null
+++ b/backtest_srclose_prob.py
@@ -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=_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 ef0 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=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"]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()
diff --git a/backtest_srclose_signal.py b/backtest_srclose_signal.py
new file mode 100644
index 0000000..dda4fb5
--- /dev/null
+++ b/backtest_srclose_signal.py
@@ -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= 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()
diff --git a/backtest_stretch.py b/backtest_stretch.py
new file mode 100644
index 0000000..253a9c7
--- /dev/null
+++ b/backtest_stretch.py
@@ -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()
diff --git a/backtest_structure.py b/backtest_structure.py
new file mode 100644
index 0000000..e9dcd68
--- /dev/null
+++ b/backtest_structure.py
@@ -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()
diff --git a/backtest_timestop.py b/backtest_timestop.py
new file mode 100644
index 0000000..62c4408
--- /dev/null
+++ b/backtest_timestop.py
@@ -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=_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 ef2} 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()
diff --git a/backtest_trailing.py b/backtest_trailing.py
new file mode 100644
index 0000000..e372396
--- /dev/null
+++ b/backtest_trailing.py
@@ -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=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 ef1 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()
diff --git a/core/__init__.py b/core/__init__.py
new file mode 100644
index 0000000..15ef411
--- /dev/null
+++ b/core/__init__.py
@@ -0,0 +1 @@
+"""Core-Module für den Oil Trading Server."""
diff --git a/core/agent.py b/core/agent.py
new file mode 100644
index 0000000..68020d4
--- /dev/null
+++ b/core/agent.py
@@ -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": "",\n'
+    '  "reasoning": "<2-4 Sätze Begründung>",\n'
+    '  "risks": ["", ""],\n'
+    '  "position_note": ""\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(),
+            }
diff --git a/core/ai.py b/core/ai.py
new file mode 100644
index 0000000..2e2eb19
--- /dev/null
+++ b/core/ai.py
@@ -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:  |  | 
+
+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:  |  | \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:  |  | \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,
+            }
diff --git a/core/analysis/__init__.py b/core/analysis/__init__.py
new file mode 100644
index 0000000..fa9328f
--- /dev/null
+++ b/core/analysis/__init__.py
@@ -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",
+]
diff --git a/core/analysis/ict.py b/core/analysis/ict.py
new file mode 100644
index 0000000..5adedac
--- /dev/null
+++ b/core/analysis/ict.py
@@ -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,
+    }
diff --git a/core/analysis/indicators.py b/core/analysis/indicators.py
new file mode 100644
index 0000000..0667bb6
--- /dev/null
+++ b/core/analysis/indicators.py
@@ -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,
+    }
diff --git a/core/analysis/m15.py b/core/analysis/m15.py
new file mode 100644
index 0000000..9fdd5f3
--- /dev/null
+++ b/core/analysis/m15.py
@@ -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,
+            }
diff --git a/core/analysis/news.py b/core/analysis/news.py
new file mode 100644
index 0000000..e4bbbc9
--- /dev/null
+++ b/core/analysis/news.py
@@ -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],
+    }
diff --git a/core/candle_logger.py b/core/candle_logger.py
new file mode 100644
index 0000000..0acd335
--- /dev/null
+++ b/core/candle_logger.py
@@ -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}
diff --git a/core/config.py b/core/config.py
new file mode 100644
index 0000000..9222143
--- /dev/null
+++ b/core/config.py
@@ -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}")
diff --git a/core/daily_levels.py b/core/daily_levels.py
new file mode 100644
index 0000000..b3416ce
--- /dev/null
+++ b/core/daily_levels.py
@@ -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: \n"
+    "RESISTANCE_LOW: \n"
+    "RESISTANCE_HIGH: = 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 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}
diff --git a/core/elliott.py b/core/elliott.py
new file mode 100644
index 0000000..e050663
--- /dev/null
+++ b/core/elliott.py
@@ -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
diff --git a/core/engine.py b/core/engine.py
new file mode 100644
index 0000000..65fa98d
--- /dev/null
+++ b/core/engine.py
@@ -0,0 +1,2018 @@
+"""
+core/engine.py — Headless Trading-Engine (ohne UI)
+====================================================
+Hält die core/-Objekte, fährt die Refresh-Loops als Threads und liefert einen
+aggregierten Snapshot. Einziges Frontend ist die Web-/Mobile-Oberfläche
+(`server.py` + `web/`).
+  MT5Data · TradeManager · TrailingManager · WaveRecommender ·
+  ElliottAnalyzer · TradersUnionProvider · TradingAgent ·
+  DailyLevels · GapAnalyzer
+
+Wichtig: MT5 ist Windows-gebunden → die Engine läuft auf demselben Rechner wie
+das MT5-Terminal. Es sollte immer nur EINE Engine-Instanz gegen das Terminal
+laufen.
+"""
+from __future__ import annotations
+import threading
+import time
+from datetime import datetime
+from zoneinfo import ZoneInfo
+
+import MetaTrader5 as mt5
+
+from core.config import (
+    HISTORY_DB_FILE, MAGIC, load_config, set_margin_buffer, set_risk_per_trade,
+    parse_zones,
+)
+from core.logger      import get_logger
+from core.history     import HistoryLogger
+from core.mt5data     import MT5Data
+from core.trader      import TradeManager
+from core.trailing    import TrailingManager
+from core.tu_rating   import TradersUnionProvider
+from core.wave_rec    import WaveRecommender
+from core.elliott     import ElliottAnalyzer
+from core.agent       import TradingAgent
+from core.news        import NewsFetcher, NewsTranslator
+from core.notify      import send_telegram
+from core.mailer      import send_graph_mail
+from core.mt5_utils   import mt5_lock
+from core.market_hours import session_state
+from core.daily_levels import DailyLevels
+from core.gaps        import GapAnalyzer
+from core.structure   import MarketStructure
+from core.candle_logger import CandleLogger
+from core.wave_rec    import (_atr as _wr_atr, _ema_last,
+                              _EMA_FAST, _EMA_SLOW, _TREND_DEADBAND, _STRETCH_MAX)
+
+log = get_logger("engine")
+
+_ATR_TF_MAP = {"M1": mt5.TIMEFRAME_M1, "M5": mt5.TIMEFRAME_M5,
+               "M15": mt5.TIMEFRAME_M15, "M30": mt5.TIMEFRAME_M30,
+               "H1": mt5.TIMEFRAME_H1}
+
+# Loop-Intervalle (Sekunden) — entsprechen den *_REFRESH_MS-Werten des Widgets
+_PRICE_S      = 0.5
+_TREND_S      = 5.0
+_POS_S        = 1.0
+_TU_S         = 300.0   # TU ist nur noch Snapshot-Anzeige (aus Empfehlung/Verdict/
+                        # Copilot entfernt) — 5 min statt 60 s reicht, spart Scrape+Log
+_AGENT_WARMUP = 20.0    # erste Beurteilung erst, wenn Daten geladen sind
+_CLOSE_ALERT_COOLDOWN_S = 300.0   # CLOSE-Telegram max. 1×/5 min (kein Spam, wenn
+                                  # der Kurs ums Signal-Level pendelt)
+_MIN_CONF_DISPLAY = 70            # Konfidenz-Schwelle für die "Order ohne Signal"-
+                                  # Warnung im Frontend (nur Anzeige-Konstante — war
+                                  # bis 2026-07-23 auf dem entfernten Dry-Run-Auto-
+                                  # Trader beheimatet, hier als eigener Wert erhalten)
+_BERLIN = ZoneInfo("Europe/Berlin")
+_SQUEEZE_NIGHT = (0, 1, 2, 3, 4, 5, 6, 7)   # Auto-Squeeze-Nacht-Sperre (Kostenfalle)
+_SQUEEZE_SR_COOLDOWN_S = 180.0   # nach S/R-Close kein Auto-Squeeze in GLEICHER Richtung
+                                 # (sonst Close-„Level hält" vs. Entry-„Level bricht"-
+                                 # Roundtrip in 1 s; 2026-07-17)
+_STARTUP_GRACE_S = 60.0          # nach Bot-Start schließt der Bot 60 s lang KEINEN
+                                 # laufenden Trade server-seitig (S/R-Close/Notfall/
+                                 # Time-Stop/Reverse) — die Daten (Preis/ATR/P(break)/
+                                 # Level) stabilisieren sich erst; verhindert den
+                                 # Sofort-Close direkt nach restart_server.bat.
+                                 # Broker-SL bleibt unberührt (läuft am Broker).
+
+# ── Kalibriertes P(Durchbruch)-Modell (logistische Regression, auf H1 trainiert,
+# auf H2 KALIBRIERT geprüft: AUC 0,71, predicted≈echt; `analyze_srwaves.py`).
+# Merkmale (in Positionsrichtung): [mom6, mom3, mit-Trend(0/1), Level-Distanz/ATR].
+# Standardisierung (mu/sd) + Gewichte (intercept + 4) aus `backtest_srclose_prob.py`.
+_PB_MU = (0.7482, 0.7036, 0.9112, 0.7072)
+_PB_SD = (1.1597, 0.9687, 0.2844, 0.6403)
+_PB_W  = (-0.5853, 0.1192, 0.7820, 0.1204, -0.2994)   # [b0, mom6, mom3, wt, dist]
+
+def _p_break(mom6: float, mom3: float, wt: float, dist: float) -> float:
+    """P(Level-Durchbruch) ∈ (0,1) aus den 4 standardisierten Merkmalen."""
+    import math
+    feat = (mom6, mom3, wt, dist)
+    z = _PB_W[0]
+    for k in range(4):
+        z += _PB_W[k + 1] * (feat[k] - _PB_MU[k]) / _PB_SD[k]
+    return 1.0 / (1.0 + math.exp(-max(-30.0, min(30.0, z))))
+
+
+class TradingEngine:
+    """Headless-Pendant zu OilWidget: hält die core-Objekte, fährt die
+    Refresh-Loops als Threads und liefert einen aggregierten Snapshot."""
+
+    def __init__(self, cfg=None):
+        self.cfg = cfg or load_config()
+        try:
+            set_margin_buffer(float(self.cfg["trading"].get("margin_buffer_pct", "90")))
+        except Exception as e:
+            log.warning(f"Margin-Buffer aus Config: {e}")
+        try:
+            set_risk_per_trade(float(self.cfg["trading"].get("risk_pct", "1.5")))
+        except Exception as e:
+            log.warning(f"Risiko/Trade aus Config: {e}")
+
+        # ── History + Telegram ────────────────────────────────────────────
+        self.history = HistoryLogger(HISTORY_DB_FILE)
+        tg = self.cfg["telegram"]
+        self.history.configure_telegram(
+            enabled = tg.get("enabled", "false").strip().lower() == "true",
+            token   = tg.get("bot_token", "").strip(),
+            chat_id = tg.get("chat_id", "").strip(),
+        )
+
+        # ── Markt / Trading / Trailing ────────────────────────────────────
+        self.data   = MT5Data()
+        self.trader = TradeManager()
+        self.trader.history = self.history
+        self.trail  = TrailingManager(self.trader)
+        self.trail.set_notify(self._on_trail_event)
+        # (Dry-Run-Auto-Trader entfernt 2026-07-23 — war Verlierer im Backtest,
+        # UI-Toggle längst weg. `min_conf`-Anzeigekonstante bleibt, s. unten.)
+
+        # ── Analyse-Module ────────────────────────────────────────────────
+        self.tu      = TradersUnionProvider()
+        self.wave    = WaveRecommender(self.tu, mt5.TIMEFRAME_M5)
+        try:
+            self.wave.set_breakout_k(float(self.cfg["trading"].get("breakout_k", "1.0")))
+        except (TypeError, ValueError):
+            pass
+        try:
+            self.wave.set_entry_room(float(self.cfg["trading"].get("entry_room_atr", "0.6")))
+        except (TypeError, ValueError):
+            pass
+        # Tageszeit-Gate aus Config (Komma-Liste von Stunden; leer = AUS). Fehlt der
+        # Key → gemessener Default (im wave_rec-Konstanten-Set). User-Vorgabe 2026-07-22:
+        # ini auf leer → Gate aus, obwohl 12/16 gemessen negativ sind.
+        _dh = self.cfg["trading"].get("dead_hours", None)
+        if _dh is not None:
+            self.wave.set_dead_hours([h.strip() for h in _dh.split(",") if h.strip()])
+        self.elliott = ElliottAnalyzer(mt5.TIMEFRAME_M5)
+        self.structure = MarketStructure()   # Marktstruktur (M30) — reine Anzeige
+        self.candles = CandleLogger(HISTORY_DB_FILE)   # M1-Candle-Log (Daten-Sammlung)
+        # News: eigener Fetch-Loop (versorgt UI /api/news UND Agent-Kontext)
+        tcfg = self.cfg["translation"]
+        translator = NewsTranslator(
+            enabled  = tcfg.get("enabled", "true").lower() == "true",
+            target   = tcfg.get("target_language", "de"),
+            provider = tcfg.get("provider", "google"),
+        )
+        self.news = NewsFetcher(translator=translator)
+        self._news_refresh_s = max(60, int(self.cfg["news"].get("refresh_min", "10")) * 60)
+        self.agent   = TradingAgent(
+            self.cfg, data=self.data, trader=self.trader, trail=self.trail,
+            tu=self.tu, wave=self.wave, history=self.history,
+            news=self.news, elliott=self.elliott)
+        # Tägliche WTI-Intraday-Level per Web-Recherche (z.ai/GLM + web_search).
+        # Nur Kontext → S/R + Anzeige, NICHT Handelsrichtung.
+        zc = self.cfg["zai"] if self.cfg.has_section("zai") else {}
+        self.daily = DailyLevels(zc.get("api_key", ""),
+                                 zc.get("base_url", "https://api.z.ai/api/paas/v4"),
+                                 zc.get("model", "glm-4.5-flash"))
+        self._daily_last_run: str | None = None   # ISO-Datum des letzten Laufs
+        self._daily_fails = 0
+        self._weekly_last_run = None               # (ISO-Jahr, KW) des letzten B4-Reports
+        # Versand-Status überlebt Neustarts — sonst schickt jeder restart_server.bat
+        # nach 07:30 den Tages-/Wochenreport ERNEUT (war real: 3× am 07.07.)
+        import os as _os
+        self._report_state_path = _os.path.join(
+            _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))),
+            "report_state.json")
+        self._load_report_state()
+        # Kurslücken-Analyse (D1): Fill-Level als S/R-Magnete (Kontext/Konfidenz,
+        # keine eigene Handelsrichtung). Refresh gedrosselt (~10 min) im Trend-Loop.
+        self.gaps = GapAnalyzer(refresh_s=600)
+
+        self.require_token = self.cfg["web"].get("require_token", "true").strip().lower() == "true"
+        self._tf_select = self.cfg["trading"].get("tf_select", "heuristic").strip()
+        self._tf_min    = self.cfg["trading"].get("tf_min", "M5").strip().upper()
+        if self._tf_min not in _ATR_TF_MAP:
+            self._tf_min = "M5"
+        # Obergrenze der Heuristik-TF (Daytrading: nur M1–M15). M30/H1 bleiben als
+        # Kontext (Filter/Konfluenz), werden aber NICHT mehr als Basis-TF gewählt.
+        self._tf_max    = self.cfg["trading"].get("tf_max", "H1").strip().upper()
+        if self._tf_max not in _ATR_TF_MAP:
+            self._tf_max = "H1"
+        self._wave_tf_label  = "M5"
+        self._last_rec: dict = {}
+        self._agent_fail_cnt = 0
+        self._close_alert_ts = 0.0      # letzter CLOSE-Telegram-Push (5-min-Cooldown)
+        self._close_alert_ticket = None # Ticket, auf das sich der Cooldown bezieht
+        self._close_alert_count = 0     # wie oft der Flip-Close-Alarm gefeuert hat
+        self._auto_sr_close = self.cfg["trading"].get("auto_sr_close", "true").lower() == "true"
+        try:
+            self._sr_close_pbreak = float(self.cfg["trading"].get("sr_close_pbreak", "0.60"))
+        except (TypeError, ValueError):
+            self._sr_close_pbreak = 0.60
+        self._sr_close_pbreak = max(0.30, min(0.90, self._sr_close_pbreak))  # sinnvolles Band
+        self._sr_close_count = 0        # wie oft der Bot am S/R automatisch schloss
+        self._sr_close_last_ticket = None   # verhindert Doppel-Auslösung je Ticket
+        # Autonomer Entry auf den gemessen-validierten Squeeze-Ausbruch (nur FLAT).
+        # ⚠ ECHTE Order — Default AUS (Autonomie nie stillschweigend an). `[trading]
+        # auto_squeeze=true` aktiviert. Dedup über (dir, level) des Ausbruchs.
+        self._auto_squeeze = self.cfg["trading"].get("auto_squeeze", "false").lower() == "true"
+        self._squeeze_skip_night = self.cfg["trading"].get("auto_squeeze_skip_night", "true").lower() == "true"
+        # Stop-&-Reverse nur, wenn explizit an (Default aus) — der Reverse ist unbelegt;
+        # der flat-Entry bleibt davon unberührt (der validierte Teil).
+        self._squeeze_reverse = self.cfg["trading"].get("auto_squeeze_reverse", "false").lower() == "true"
+        self._squeeze_last_sig = None      # Dedup Entry (flat)
+        self._squeeze_reverse_sig = None   # Dedup Reverse-Close (Gegen-Trade)
+        self._squeeze_entry_count = 0
+        self._sr_close_ts = 0.0            # Zeit + Richtung des letzten S/R-Closes
+        self._sr_close_dir = None          # → Cooldown gegen Gleichrichtungs-Roundtrip
+        self._sr_close_min_gain_armed_ticket = None  # Ticket, für das die %-Schwelle schon
+                                            # aus echter Margin berechnet wurde (Retry-Schutz,
+                                            # s. `_check_auto_close`)
+        try:
+            self._sr_close_min_gain = max(0.0, float(
+                self.cfg["trading"].get("sr_close_min_gain", "0")))
+        except (TypeError, ValueError):
+            self._sr_close_min_gain = 0.0
+        # Gewinn-Close automatisch auf X% des Einsatzes (Margin) DIESER Position setzen,
+        # sobald sie eröffnet wird (User-Vorgabe 2026-07-23) — überschreibt den manuell
+        # gemerkten/persistierten Wert bei jeder ECHT NEUEN Position (nicht beim
+        # Wiedererkennen nach Neustart, s. `_check_auto_close`). 0 = aus (dann bleibt der
+        # manuell gesetzte/gemerkte Wert wie bisher maßgeblich).
+        try:
+            self._sr_close_min_gain_pct = max(0.0, float(
+                self.cfg["trading"].get("sr_close_min_gain_pct", "1.0")))
+        except (TypeError, ValueError):
+            self._sr_close_min_gain_pct = 1.0
+        self._pb_state = None   # geglättetes P(break) + Hysterese-Zone je Level
+                                # (gemessen `analyze_pbreak_flicker.py`: mom3 der
+                                # laufenden Kerze ließ P ~7–15 Pp je M5-Bar springen)
+        # UI-Einstellungen neustart-fest (Fix 2026-07-19): Toggles/Werte, die über
+        # die App gesetzt werden (Auto-Squeeze, S/R-Auto-Close, Mindestgewinn),
+        # überschreiben nach dem Laden die ini-Defaults — vorher war z. B. ein am
+        # Handy gesetzter Mindestgewinn nach jedem restart_server.bat wieder weg.
+        import os as _os2
+        self._runtime_state_path = _os2.path.join(
+            _os2.path.dirname(_os2.path.dirname(_os2.path.abspath(__file__))),
+            "runtime_state.json")
+        self._load_runtime_state()
+        self._mql5_files_dir = None     # \MQL5\Files (lazy, für den Indikator)
+        self._export_mql5 = self.cfg["trading"].get("export_mql5_levels", "true").lower() == "true"
+        self._draw_last = {"r": None, "s": None}   # letzte gezeichnete S/R (Hysterese)
+        try:      # WHT-Quellensteuer (Broker behält % je GEWINN ein) → Netto-Anzeige
+            self._wht_pct = max(0.0, float(self.cfg["trading"].get("wht_pct", "0") or 0))
+        except (TypeError, ValueError):
+            self._wht_pct = 0.0
+        self._draw_cache: tuple[float, dict] | None = None   # (ts, result) TTL ~1 s —
+        # 4 Aufrufer (Snapshot 2×, pos_loop, CSV, Charts): EIN Ergebnis je Sekunde
+        # garantiert identische Linien in Kachel/Chart/Close-Meldung + spart Lock-Last
+        self._emergency_loss: float | None = None   # aktiver Notfall-Stop (Verlust)
+        self._last_pos_ticket = None                 # für Auto-Arm beim Öffnen
+        # Beim Öffnen einer neuen Position wird der Notfall-Stop automatisch auf
+        # den ZULETZT genutzten Wert gesetzt (gemerkt über Trades hinweg). Start-
+        # wert = [trading] auto_emergency_loss (0 = Auto-Arm aus).
+        try:
+            self._emergency_remember = float(self.cfg["trading"].get("auto_emergency_loss", "10") or 0)
+        except (TypeError, ValueError):
+            self._emergency_remember = 10.0
+        # Auto-Notfall-Stop als % des Kontos (User-Vorgabe 2026-07-15): ist
+        # `[trading] auto_emergency_pct` > 0, wird beim Öffnen der Stop auf
+        # pct% der Balance gesetzt (skaliert mit dem Konto) STATT auf den
+        # gemerkten Fixwert. 0 = aus (Fixwert-Verhalten wie bisher).
+        try:
+            self._emergency_pct = max(0.0, float(self.cfg["trading"].get("auto_emergency_pct", "0") or 0))
+        except (TypeError, ValueError):
+            self._emergency_pct = 0.0
+        # Notfall-Stop als % der EINSATZ-Margin dieser Position (User-Vorgabe
+        # 2026-07-23, `[trading] auto_emergency_margin_pct=3.0`) — dritter, margin-
+        # basierter Modus, hat Vorrang vor dem Balance-%-Modus (`_emergency_pct`)
+        # und dem Fixwert, wenn >0. Retry-sicher wie `_sr_close_min_gain_pct`
+        # armiert (s. `_check_auto_close`): `order_calc_margin` kann im ersten Tick
+        # nach dem Öffnen noch 0 liefern. ⚠ Bekannte Kopplungs-Regel (CLAUDE.md):
+        # ein %-Notfall-Stop ist unter Margin-Sizing enger als der 2×ATR-SL — bewusste
+        # User-Vorgabe trotzdem, kein Gegen-Argument wiederholen.
+        try:
+            self._emergency_margin_pct = max(0.0, float(
+                self.cfg["trading"].get("auto_emergency_margin_pct", "0") or 0))
+        except (TypeError, ValueError):
+            self._emergency_margin_pct = 0.0
+        self._emergency_margin_armed_ticket = None  # Retry-Schutz wie bei sr_close_min_gain
+        # Gewinn-mitnehmen (Take-Profit) — symmetrisch zum Notfall-Stop: schließt
+        # bei P&L ≥ +Wert. Gemerkter Wert über Trades hinweg (0 = Auto-Arm aus).
+        self._takeprofit: float | None = None
+        try:
+            self._takeprofit_remember = float(self.cfg["trading"].get("auto_takeprofit", "0") or 0)
+        except (TypeError, ValueError):
+            self._takeprofit_remember = 0.0
+        # Notfall-Stop/TP überleben Neustarts: sonst re-armiert ein restart_server.bat
+        # eine OFFENE Position auf die 2%-Automatik und verwirft eine manuelle Über-
+        # steuerung (real 2026-07-17 14:30: −25 € → −19,42 €, dann Notfall-Close bei
+        # −19,64). Persistiert {ticket, loss, tp}; in _check_auto_close wiederhergestellt,
+        # solange dasselbe Ticket noch offen ist.
+        self._emergency_state_path = _os.path.join(
+            _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))),
+            "emergency_state.json")
+        self._emergency_state_ticket = None
+        self._emergency_state_loss = None
+        self._emergency_state_tp = None
+        self._load_emergency_state()
+        # Startup-Schonfrist: bis zu diesem Zeitpunkt kein server-seitiger Auto-Close
+        # (in start() gesetzt). Konfigurierbar via [trading] startup_close_grace_s.
+        try:
+            self._startup_grace_s = max(0.0, float(
+                self.cfg["trading"].get("startup_close_grace_s", str(_STARTUP_GRACE_S))))
+        except (TypeError, ValueError):
+            self._startup_grace_s = _STARTUP_GRACE_S
+        self._no_autoclose_until = 0.0
+        self._running        = False
+        self._connected      = False
+        self._error: str | None = None
+        self._threads: list[threading.Thread] = []
+        # Snapshot-Cache: ein berechneter Snapshot wird über alle WS-Clients +
+        # HTTP-Aufrufe geteilt (TTL) → SQLite/Verdict nur 1×/TTL statt N×/s.
+        self._snap_cache: dict | None = None
+        self._snap_cache_ts: float = 0.0
+        self._snap_lock = threading.Lock()
+        self._sqmon_cache: dict | None = None      # Auto-Squeeze-Monitor (B4), TTL 60 s
+        self._sqmon_cache_ts: float = 0.0
+
+    # ══════════════════════════════════════════════════════════════════════
+    #  LIFECYCLE
+    # ══════════════════════════════════════════════════════════════════════
+    def start(self) -> bool:
+        """Verbindet MT5, übernimmt offene Positionen und startet die Loops."""
+        preferred = self.cfg["trading"].get("last_symbol", "").strip() or None
+        if not self.data.connect(preferred_symbol=preferred):
+            self._error = self.data.error
+            log.error(f"MT5-Connect fehlgeschlagen: {self.data.error}")
+            return False
+        self._connected = True
+        self._adopt_positions()
+
+        # Start-Timeframe: fester tf_select hat Vorrang, sonst letzte Wahl aus
+        # der Config; Heuristik/Agent justieren danach laufend.
+        if self._tf_select.upper() in _ATR_TF_MAP:
+            saved_tf = self._tf_select.upper()
+        else:
+            saved_tf = self.cfg["trading"].get("atr_tf", "M5").strip().upper()
+        if saved_tf not in _ATR_TF_MAP:
+            saved_tf = "M5"
+        # Start-TF in das erlaubte Band [tf_min … tf_max] klemmen (Heuristik-Modus)
+        if self._tf_select == "heuristic":
+            order = list(_ATR_TF_MAP.keys())
+            lo = order.index(self._tf_min); hi = order.index(self._tf_max)
+            r  = order.index(saved_tf)
+            saved_tf = order[min(max(r, lo), hi)]
+        self._wave_tf_label = saved_tf
+        const = _ATR_TF_MAP[saved_tf]
+        self.wave.set_timeframe(const)
+        self.elliott.set_timeframe(const)
+        self.trail.set_atr_tf_override(const)
+
+        threading.Thread(target=self.trader.reconcile_open_trades,
+                         daemon=True, name="reconcile").start()
+        # RSS-Feeds im Hintergrund validieren (ausgefallene werden ersetzt)
+        threading.Thread(target=self.news.validate_feeds,
+                         daemon=True, name="feed-validator").start()
+
+        # Startup-Schonfrist scharf: die nächsten `_startup_grace_s` s schließt der
+        # Bot keinen laufenden Trade selbst (S/R-Close/Notfall/Time-Stop/Reverse) —
+        # verhindert Sofort-Close eines adoptierten Trades auf noch-instabilen Daten.
+        self._no_autoclose_until = time.time() + self._startup_grace_s
+        self.trail.set_no_close_until(self._no_autoclose_until)
+        if self._startup_grace_s > 0:
+            log.info(f"Startup-Schonfrist: {self._startup_grace_s:.0f} s kein Auto-Close")
+
+        self._running = True
+        # _tu_loop stillgelegt (2026-07-19): TU ist aus Empfehlung/Verdict/Copilot
+        # raus UND die Dashboard-Kachel ist ausgeblendet → der 5-min-Scrape lief
+        # für niemanden. Reaktivieren = self._tu_loop wieder in die Liste.
+        for loop in (self._price_loop, self._trend_loop, self._pos_loop,
+                     self._news_loop, self._tf_loop, self._agent_loop,
+                     self._daily_loop):
+            t = threading.Thread(target=loop, daemon=True, name=loop.__name__)
+            t.start()
+            self._threads.append(t)
+
+        log.info(f"Engine gestartet · Symbol {self.data.symbol} · TF {saved_tf}")
+        return True
+
+    def stop(self):
+        self._running = False
+        try:
+            self.data.disconnect()
+        except Exception:
+            pass
+
+    def _adopt_positions(self):
+        """Beim Start vorhandene MT5-Positionen übernehmen (wie main() im Widget)."""
+        try:
+            all_pos = mt5.positions_get() or []
+            log.info(f"Sofort-Adoption: {len(all_pos)} offene Position(en)")
+            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]
+                if pick.symbol != self.data.symbol:
+                    log.info(f"Symbol-Switch beim Start: {self.data.symbol} → "
+                             f"{pick.symbol} (offene Position T={pick.ticket})")
+                    ok, msg = self.data.switch_symbol(pick.symbol)
+                    if not ok:
+                        log.warning(f"Symbol-Switch fehlgeschlagen: {msg}")
+            self.trader.refresh(self.data.symbol)
+        except Exception as e:
+            log.warning(f"Sofort-Adoption: {e}", exc_info=True)
+
+    # ══════════════════════════════════════════════════════════════════════
+    #  LOOPS  (jeder MT5-Call sichert sich intern den globalen mt5_lock)
+    # ══════════════════════════════════════════════════════════════════════
+    def _price_loop(self):
+        cnt = 0
+        while self._running:
+            cnt += 1
+            try:
+                self.data.fetch_price()
+                s = self.data.snapshot()
+                if s.get("bid") and s.get("ask") and self.data.symbol:
+                    try:
+                        self.trail.on_price(self.data.symbol, s["bid"], s["ask"])
+                    except Exception as te:
+                        log.warning(f"trail.on_price: {te}")
+            except Exception as e:
+                log.error(f"_price_loop#{cnt}: {e}", exc_info=True)
+            time.sleep(_PRICE_S)
+
+    def _trend_loop(self):
+        while self._running:
+            try:
+                self.data.fetch_trend()
+                if self.data.symbol:
+                    # S/R-Kontext an das Wellenmodul geben (für die Konfidenz)
+                    try:
+                        srl = self._sr_levels(self.data.snapshot())
+                        self.wave.set_sr_context(srl.get("resistance"),
+                                                 srl.get("support"))
+                    except Exception as se:
+                        log.warning(f"set_sr_context: {se}")
+                    try:    self.gaps.maybe_refresh(self.data.symbol)
+                    except Exception as ge: log.warning(f"gaps.maybe_refresh: {ge}")
+                    try:    self.wave.refresh_market(self.data.symbol)
+                    except Exception as we: log.warning(f"wave.refresh_market: {we}")
+                    try:    self.elliott.refresh_market(self.data.symbol)
+                    except Exception as ee: log.warning(f"elliott.refresh_market: {ee}")
+                    try:    self.structure.refresh_market(self.data.symbol)
+                    except Exception as se2: log.warning(f"structure.refresh_market: {se2}")
+                    try:    self.candles.log(self.data.symbol)   # M1-Candle-Log (~55 s)
+                    except Exception as ce: log.warning(f"candles.log: {ce}")
+                    try:    self._evaluate_pbreak_predictions()  # ~alle 5 min, self-throttled
+                    except Exception as pe: log.warning(f"pbreak_predictions: {pe}")
+                self._run_analysis()
+            except Exception as e:
+                log.error(f"_trend_loop: {e}", exc_info=True)
+            self._sleep(_TREND_S)
+
+    def _pos_loop(self):
+        while self._running:
+            try:
+                if self.data.symbol:
+                    self.trader.refresh(self.data.symbol)
+                    self._check_auto_close()
+                    self._check_sr_close()
+                    self._check_auto_squeeze()   # autonomer Entry auf Squeeze-Ausbruch
+            except Exception as e:
+                log.error(f"_pos_loop: {e}", exc_info=True)
+            self._sleep(_POS_S)
+
+    def set_emergency(self, value) -> float | None:
+        """Notfall-Verlust-Schwelle setzen/löschen (Kontowährung, als positiver
+        Betrag gespeichert). None/leer/0 → aus. Rückgabe = aktiver Wert."""
+        try:
+            v = abs(float(value)) if value not in (None, "", "0", 0) else None
+            if v is not None and v <= 0:
+                v = None
+        except (TypeError, ValueError):
+            v = None
+        self._emergency_loss = v
+        if v is not None:
+            self._emergency_remember = v   # für die nächste Position merken
+        else:
+            self._emergency_remember = 0.0  # „aus" schaltet auch den Auto-Arm ab
+        log.info(f"Notfall-Stop {('gesetzt: -' + format(v, '.2f')) if v else 'aus'}")
+        self._save_emergency_state()   # Handeingabe neustart-fest (Fix 2026-07-17)
+        return self._emergency_loss
+
+    def set_takeprofit(self, value) -> float | None:
+        """Gewinn-Ziel setzen/löschen (Kontowährung, positiver Betrag). None/leer/0
+        → aus. Gemerkt für die nächste Position. Rückgabe = aktiver Wert."""
+        try:
+            v = abs(float(value)) if value not in (None, "", "0", 0) else None
+            if v is not None and v <= 0:
+                v = None
+        except (TypeError, ValueError):
+            v = None
+        self._takeprofit = v
+        if v is not None:
+            self._takeprofit_remember = v
+        else:
+            self._takeprofit_remember = 0.0  # „aus" schaltet auch den Auto-Arm ab
+        log.info(f"Gewinn-Ziel {('gesetzt: +' + format(v, '.2f')) if v else 'aus'}")
+        self._save_emergency_state()   # Handeingabe neustart-fest (Fix 2026-07-17)
+        return self._takeprofit
+
+    def get_bars(self, tf_label: str, n: int = 90) -> dict:
+        """OHLC-Bars + EMA12/50 + Trend je Timeframe für den Charts-Tab. Läuft unter
+        `mt5_lock`. Trend = EMA12 vs EMA50 mit Totband (_TREND_DEADBAND×ATR)."""
+        import MetaTrader5 as mt5
+        from core.mt5_utils import mt5_lock
+        from core.wave_rec import _ema_series, _atr, _EMA_FAST, _EMA_SLOW, _TREND_DEADBAND
+        tf_map = {"M1": mt5.TIMEFRAME_M1, "M5": mt5.TIMEFRAME_M5, "M15": mt5.TIMEFRAME_M15,
+                  "M30": mt5.TIMEFRAME_M30, "H1": mt5.TIMEFRAME_H1}
+        tf = tf_map.get(tf_label); sym = self.data.symbol
+        if tf is None or not sym:
+            return {"tf": tf_label, "bars": [], "error": "kein TF/Symbol"}
+        n = max(30, min(int(n), 200))
+        try:
+            with mt5_lock(timeout=5) as got:
+                if not got:
+                    return {"tf": tf_label, "bars": [], "error": "busy"}
+                rates = mt5.copy_rates_from_pos(sym, tf, 0, n + _EMA_SLOW)
+        except Exception as e:
+            return {"tf": tf_label, "bars": [], "error": str(e)}
+        if rates is None or len(rates) == 0:
+            return {"tf": tf_label, "bars": [], "error": "keine Daten"}
+        closes = [float(b["close"]) for b in rates]
+        highs  = [float(b["high"])  for b in rates]
+        lows   = [float(b["low"])   for b in rates]
+        e12 = _ema_series(closes, _EMA_FAST); e50 = _ema_series(closes, _EMA_SLOW)
+        atr = _atr(highs, lows, closes) or 0.0
+        d = e12[-1] - e50[-1]
+        dead = _TREND_DEADBAND * atr if atr else 0.0
+        trend = "flat" if abs(d) <= dead else ("up" if d > 0 else "down")
+        k = min(n, len(rates))
+        bars = [{"t": int(b["time"]), "o": float(b["open"]), "h": float(b["high"]),
+                 "l": float(b["low"]), "c": float(b["close"])} for b in rates[-k:]]
+        # S/R-Linien fürs Chart-Overlay: echte Pivot-Struktur (`_draw_levels`,
+        # identisch zum Auto-Close-System) — NICHT mehr die stale [zones]-Config.
+        try:
+            dl = self._draw_levels()
+            levels = {"res": dl.get("res", []), "sup": dl.get("sup", []), "zones": []}
+        except Exception:
+            levels = {"res": [], "sup": [], "zones": []}
+        # Regressionskanal — nur M30 (dort ist die Marktstruktur berechnet), als
+        # Linien-Arrays deckungsgleich mit den Bars (Overlay im Charts-Tab).
+        channel = None
+        if tf_label == "M30":
+            try:
+                from core.structure import channel_series
+                channel = channel_series(closes, atr, k)
+            except Exception:
+                channel = None
+        return {"tf": tf_label, "symbol": sym, "trend": trend, "bars": bars,
+                "ema12": [round(x, 3) for x in e12[-k:]],
+                "ema50": [round(x, 3) for x in e50[-k:]],
+                "levels": levels, "channel": channel,
+                "broker_utc_offset": 3 * 3600}
+
+    def _in_startup_grace(self) -> bool:
+        """True in den ersten `_startup_grace_s` s nach Bot-Start — dann schließt der
+        Bot keinen laufenden Trade selbst (Daten noch nicht stabil nach Neustart)."""
+        return time.time() < self._no_autoclose_until
+
+    def _check_auto_close(self):
+        """Server-seitiger Auto-Close (wirkt auch bei gesperrtem Handy): Notfall-Stop
+        (P&L ≤ −Schwelle) UND Gewinn-mitnehmen (P&L ≥ +Ziel). Beide werden bei einer
+        NEUEN Position automatisch auf den zuletzt genutzten Wert gesetzt; manuelles
+        Übersteuern im laufenden Trade bleibt erhalten."""
+        ps = self.trader.snapshot()
+        ticket = ps.get("ticket")
+        if not ticket:
+            self._last_pos_ticket = None
+            self._emergency_loss = None
+            self._takeprofit = None
+            return
+        if ticket != self._last_pos_ticket:      # neue/wiedererkannte Position
+            self._last_pos_ticket = ticket
+            if ticket == self._emergency_state_ticket:
+                # Dieselbe (noch offene) Position nach Neustart/Wiedererkennung →
+                # persistierten Wert WIEDERHERSTELLEN (inkl. manueller Übersteuerung),
+                # NICHT auf die pct-Automatik zurücksetzen (Fix 2026-07-17: Neustart
+                # bei offenem Trade kappte sonst die von Hand gesetzten −25 € auf −19,42).
+                self._emergency_loss = self._emergency_state_loss
+                self._takeprofit = self._emergency_state_tp
+                self._sr_close_min_gain_armed_ticket = ticket  # wiederhergestellter Wert
+                                            # gilt als final — NICHT auf 1% zurücksetzen
+                self._emergency_margin_armed_ticket = ticket   # dito für den margin-%-Stop
+                log.info(f"Auto-Close wiederhergestellt (Pos {ticket}): Stop "
+                         f"{('-'+format(self._emergency_loss,'.2f')) if self._emergency_loss else 'aus'} · TP "
+                         f"{('+'+format(self._takeprofit,'.2f')) if self._takeprofit else 'aus'}")
+            else:                                # echt neue Position → armieren
+                if self._emergency_margin_pct > 0:
+                    pass   # wird unten margin-basiert (retry-sicher) gesetzt
+                elif self._emergency_pct > 0 and self.data.balance:
+                    # % des Kontos (skaliert dynamisch, User-Vorgabe)
+                    self._emergency_loss = round(self._emergency_pct / 100.0 * float(self.data.balance), 2)
+                elif self._emergency_remember and self._emergency_remember > 0:
+                    self._emergency_loss = float(self._emergency_remember)
+                if self._takeprofit_remember and self._takeprofit_remember > 0:
+                    self._takeprofit = float(self._takeprofit_remember)
+                log.info(f"Auto-Close armiert (Pos {ticket}): Stop "
+                         f"{('-'+format(self._emergency_loss,'.2f')) if self._emergency_loss else 'aus'} · TP "
+                         f"{('+'+format(self._takeprofit,'.2f')) if self._takeprofit else 'aus'}")
+                self._save_emergency_state()
+        # Gewinn-Close (Mindestgewinn für den S/R-Auto-Close) auf X% des EINSATZES
+        # dieser Position setzen (Margin kommt frisch aus dem trader.refresh()
+        # unmittelbar vor diesem Aufruf in _pos_loop). Läuft NICHT nur einmalig beim
+        # Ticket-Wechsel, sondern jeden Tick erneut, bis es geklappt hat (Fix
+        # 2026-07-23): war `order_calc_margin` genau im ersten Tick nach dem Öffnen
+        # noch 0/nicht verfügbar, blieb die Schwelle sonst dauerhaft auf dem STALEN
+        # Wert der vorherigen Position hängen (`_last_pos_ticket` war da schon
+        # umgesetzt, der Ticket-Wechsel-Zweig feuert nur 1×) — konnte den Trade zu
+        # früh (falscher, zu niedriger Alt-Wert) ODER dauerhaft mit falscher Schwelle
+        # laufen lassen.
+        if (self._sr_close_min_gain_pct > 0
+                and self._sr_close_min_gain_armed_ticket != ticket):
+            margin = ps.get("margin") or 0.0
+            if margin > 0:
+                self._sr_close_min_gain = round(
+                    self._sr_close_min_gain_pct / 100.0 * margin, 2)
+                self._sr_close_min_gain_armed_ticket = ticket
+                self._save_runtime_state()
+                log.info(f"Gewinn-Close armiert (Pos {ticket}): "
+                         f"+{self._sr_close_min_gain:.2f} ({self._sr_close_min_gain_pct:.0f}% Margin {margin:.2f})")
+        # Notfall-Stop als % der Margin — gleiches Retry-Muster (margin-Wert kann im
+        # ersten Tick nach dem Öffnen noch 0 sein). Hat Vorrang vor Balance-%/Fixwert
+        # (die im "echt neue Position"-Zweig oben bewusst übersprungen wurden, wenn
+        # `_emergency_margin_pct>0`).
+        if (self._emergency_margin_pct > 0
+                and self._emergency_margin_armed_ticket != ticket):
+            margin = ps.get("margin") or 0.0
+            if margin > 0:
+                self._emergency_loss = round(
+                    self._emergency_margin_pct / 100.0 * margin, 2)
+                self._emergency_margin_armed_ticket = ticket
+                self._save_emergency_state()
+                log.info(f"Notfall-Stop armiert (Pos {ticket}): "
+                         f"-{self._emergency_loss:.2f} ({self._emergency_margin_pct:.0f}% Margin {margin:.2f})")
+        if self._in_startup_grace():      # Startup-Schonfrist: armieren ja, schließen nein
+            return
+        pnl = ps.get("pnl")
+        if pnl is None:
+            return
+        tg = self.cfg["telegram"]
+        tg_on = (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"))
+        sym = self.data.symbol or ""
+
+        def _do(reason, ok_icon, ok_word, level_txt):
+            err = self.trader.close(reason=reason)
+            if err:
+                log.error(f"{ok_word} fehlgeschlagen: {err}")
+                if tg_on:
+                    try: send_telegram(f"⚠ {ok_word} FEHLGESCHLAGEN\n{sym} P&L {pnl:+.2f} "
+                                       f"({level_txt}) — bitte MANUELL schließen! ({err})",
+                                       tg["bot_token"], tg["chat_id"])
+                    except Exception as e: log.warning(f"Auto-Close-Telegram (Fehler): {e}")
+                return False
+            if tg_on:
+                try: send_telegram(f"{ok_icon} {ok_word}\n{sym} bei P&L {pnl:+.2f} "
+                                   f"({level_txt}) geschlossen.", tg["bot_token"], tg["chat_id"])
+                except Exception as e: log.warning(f"Auto-Close-Telegram: {e}")
+            return True
+
+        thr = self._emergency_loss
+        if thr and pnl <= -abs(thr):
+            log.warning(f"🚨 NOTFALL-CLOSE: P&L {pnl:+.2f} ≤ -{abs(thr):.2f}")
+            if _do("emergency", "🚨", "Notfall-Close", f"Schwelle -{abs(thr):.2f}"):
+                self._emergency_loss = None
+            return
+        tp = self._takeprofit
+        if tp and pnl >= abs(tp):
+            log.info(f"💰 GEWINN-CLOSE: P&L {pnl:+.2f} ≥ +{abs(tp):.2f}")
+            if _do("takeprofit", "💰", "Gewinn mitgenommen", f"Ziel +{abs(tp):.2f}"):
+                self._takeprofit = None
+            return
+
+    def _check_sr_close(self):
+        """Automatischer S/R-Close (User-Vorgabe): schließt eine Position **im Plus**
+        selbstständig, wenn die gemessene Bedingung greift (Kurs klebt am gegenüber-
+        liegenden Level + schwacher Anlauf → Durchbruch ~26 %, Close schlägt Laufen-
+        lassen; `backtest_srbreak.py`). Nur bei `auto_sr_close=true`, genau 1×/Ticket,
+        wirkt auch bei gesperrtem Handy. Fail-safe."""
+        if not self._auto_sr_close:
+            return
+        if self._in_startup_grace():      # Startup-Schonfrist: kein Sofort-Close nach Neustart
+            return
+        ps = self.trader.snapshot()
+        ticket = ps.get("ticket")
+        if not ticket:
+            self._sr_close_last_ticket = None
+            return
+        if ticket == self._sr_close_last_ticket:   # bereits ausgelöst → nicht erneut
+            return
+        pnl = ps.get("pnl")
+        if pnl is None or pnl <= 0:                 # NUR bei Trade im Plus (User-Vorgabe)
+            return
+        # Optionaler Mindestgewinn (User-Vorgabe; Default 0=aus — gemessen ist ohne
+        # besser): unter der Schwelle wird am Level NICHT geschlossen, Trade läuft.
+        if self._sr_close_min_gain > 0 and pnl < self._sr_close_min_gain:
+            return
+        hint = self._sr_close_hint(self.data.snapshot(), ps, self.wave.snapshot())
+        if not hint or not hint.get("close"):      # am Level, aber P(break) ≥ Schwelle → laufen lassen
+            return
+        self._sr_close_last_ticket = ticket         # vor dem Close markieren (kein Doppel)
+        sym = self.data.symbol or ""
+        _confirm = hint.get("confirm")
+        log.info(f"🎯 S/R-AUTO-CLOSE: {sym} P&L {pnl:+.2f} — am Level {hint['level']}, "
+                 f"Durchbruch nur ~{hint['p_break']}% (< {self._sr_close_pbreak:.0%})"
+                 + (f" — Bestätigung erst ab {_confirm}" if _confirm else "")
+                 + " → Gewinn sichern")
+        err = self.trader.close(reason="sr_close")
+        tg = self.cfg["telegram"]
+        tg_on = (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"))
+        if err:
+            log.error(f"S/R-Auto-Close fehlgeschlagen: {err}")
+            self._sr_close_last_ticket = None        # Fehlschlag → nächster Tick erneut
+            if tg_on:
+                try: send_telegram(f"⚠ S/R-Close FEHLGESCHLAGEN\n{sym} P&L {pnl:+.2f} "
+                                   f"— bitte MANUELL schließen! ({err})",
+                                   tg["bot_token"], tg["chat_id"])
+                except Exception as e: log.warning(f"S/R-Close-Telegram (Fehler): {e}")
+            return
+        # Richtung + Zeit merken → Auto-Squeeze-Cooldown gegen Gleichrichtungs-Roundtrip
+        self._sr_close_dir = "LONG" if ps.get("order_type") == 0 else "SHORT"
+        self._sr_close_ts = time.time()
+        self._sr_close_count += 1
+        if tg_on:
+            try: send_telegram(f"🎯 S/R-Auto-Close\n{sym} bei P&L {pnl:+.2f} geschlossen "
+                               f"— Kurs klebte am Level {hint['level']} mit schwachem Anlauf "
+                               f"(Durchbruch ~{hint['p_break']}% unwahrscheinlich).",
+                               tg["bot_token"], tg["chat_id"])
+            except Exception as e: log.warning(f"S/R-Close-Telegram: {e}")
+
+    def _check_auto_squeeze(self):
+        """Autonomer Entry auf den gemessen-validierten Volatilitäts-Squeeze-Ausbruch
+        (`wave.squeeze.state == 'active'`), NUR wenn flat. Feuert **1× je Ausbruch**
+        (Dedup über dir+level; re-armt, sobald der Squeeze klärt). Emergency-Stop
+        armiert beim Öffnen automatisch (2 % Konto, via `_check_auto_close`). Fail-safe."""
+        if not self._auto_squeeze:
+            return
+        try:
+            sq = (self.wave.snapshot() or {}).get("squeeze") or {}
+            if sq.get("state") != "active":
+                self._squeeze_last_sig = None          # Ausbruch geklärt → re-armen
+                return
+            d = sq.get("dir"); lvl = sq.get("level")
+            if d not in ("LONG", "SHORT") or lvl is None:
+                return
+            sig = (d, round(float(lvl), 2))
+            # ── Dead-Hour-Guard (0–7 Uhr Berlin, 2026-07-17): nachts KOMPLETT inaktiv —
+            # kein Entry UND kein Reverse (der Squeeze ist in dünner Nacht-Liquidität in
+            # BEIDE Richtungen unzuverlässig; die 3 Nacht-Squeezes waren die ganze
+            # Verlustquelle). Setzt keine Dedup-Marker → feuert nach 08:00, falls noch
+            # aktiv. `auto_squeeze_skip_night=false` schaltet aus.
+            if self._squeeze_skip_night and datetime.now(_BERLIN).hour in _SQUEEZE_NIGHT:
+                return
+
+            ps = self.trader.snapshot()
+            ticket = ps.get("ticket")
+            if ticket:
+                pos_dir = "LONG" if ps.get("order_type") == 0 else "SHORT"
+                if pos_dir == d:
+                    self._squeeze_last_sig = sig       # gleiche Richtung → schon dabei
+                    return
+                # ── Stop-&-Reverse (Default AUS, `auto_squeeze_reverse`): unbelegt +
+                # dreht am Ausbruch-Extrem (real 2026-07-17 18:55). Ist er aus, bleibt
+                # der Gegen-Trade unangetastet — Drehen ist dann MANUELLE Entscheidung.
+                # `_squeeze_last_sig` NICHT setzen → schließt der Gegen-Trade (SL/manuell),
+                # kann der flat-Entry auf denselben Ausbruch noch feuern.
+                if not self._squeeze_reverse:
+                    return
+                if self._in_startup_grace():   # Startup-Schonfrist: kein Reverse-Close nach Neustart
+                    return
+                # Trade läuft GEGEN den Ausbruch → sofort schließen. Der nächste (flat)
+                # Tick eröffnet dann in Ausbruchsrichtung über den Entry-Pfad. Dedup über
+                # reverse_sig (kein Doppel-Close, während die Position noch schließt).
+                if sig == self._squeeze_reverse_sig:
+                    return
+                self._squeeze_reverse_sig = sig
+                sym = self.data.symbol or ""
+                pnl = ps.get("pnl")
+                log.info(f"🔄 AUTO-SQUEEZE-REVERSE: {pos_dir}-Position gegen {d}-Ausbruch "
+                         f"@ {lvl} (P&L {pnl:+.2f} EUR) → sofort schließen, dann drehen")
+                err = self.trader.close(reason="squeeze_reverse")
+                tg = self.cfg["telegram"]
+                tg_on = (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"))
+                if err:
+                    log.error(f"Auto-Squeeze-Reverse-Close fehlgeschlagen: {err}")
+                    self._squeeze_reverse_sig = None    # Fehlschlag → nächster Tick erneut
+                elif tg_on:
+                    try: send_telegram(f"🔄 Auto-Squeeze-Reverse\n{sym} {pos_dir}-"
+                                       f"Position (P&L {pnl:+.2f}) gegen {d}-Ausbruch @ {lvl} "
+                                       f"geschlossen — dreht auf {d}.",
+                                       tg["bot_token"], tg["chat_id"])
+                    except Exception as e: log.warning(f"Squeeze-Reverse-Telegram: {e}")
+                return
+
+            # FLAT → Entry-Pfad
+            if sig == self._squeeze_last_sig:
+                return                                 # diesen Ausbruch schon gehandelt
+            # Cooldown: nicht in GLEICHER Richtung direkt nach einem S/R-Close wieder
+            # eröffnen — der Close ging von „Level hält" aus, ein sofortiger Gleich-
+            # richtungs-Entry wäre der widersprüchliche 1-s-Roundtrip (2026-07-17).
+            # `_squeeze_last_sig` NICHT setzen → nach Ablauf feuert der Ausbruch, falls
+            # noch aktiv. Gegenrichtung (Reverse) bleibt unberührt (läuft oben).
+            if (d == self._sr_close_dir
+                    and time.time() - self._sr_close_ts < _SQUEEZE_SR_COOLDOWN_S):
+                return
+            self._squeeze_last_sig = sig
+            if isinstance(self._last_rec, dict):
+                self._last_rec["setup"] = f"SQUEEZE_{d}"
+            log.info(f"🚀 AUTO-SQUEEZE-ENTRY: {d} @ Ausbruch {lvl} — eröffne selbständig")
+            err = self.open_long() if d == "LONG" else self.open_short()
+            sym = self.data.symbol or ""
+            tg = self.cfg["telegram"]
+            tg_on = (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"))
+            if err:
+                log.error(f"Auto-Squeeze-Entry fehlgeschlagen: {err}")
+                if tg_on:
+                    try: send_telegram(f"⚠ Auto-Squeeze-Entry FEHLGESCHLAGEN\n"
+                                       f"{sym} {d} @ {lvl} — {err}", tg["bot_token"], tg["chat_id"])
+                    except Exception as e: log.warning(f"Squeeze-Telegram (Fehler): {e}")
+                return
+            self._squeeze_entry_count += 1
+            if tg_on:
+                try: send_telegram(f"🚀 Auto-Squeeze-Entry\n{sym} {d} selbständig "
+                                   f"eröffnet (Ausbruch aus Kompression @ {lvl}).",
+                                   tg["bot_token"], tg["chat_id"])
+                except Exception as e: log.warning(f"Squeeze-Telegram: {e}")
+        except Exception as e:
+            log.error(f"_check_auto_squeeze: {e}", exc_info=True)
+
+    def set_auto_squeeze(self, on: bool) -> bool:
+        """Autonomen Squeeze-Entry an/aus (UI/Config). Rückgabe = neuer Zustand."""
+        self._auto_squeeze = bool(on)
+        self._squeeze_last_sig = None
+        log.info(f"Auto-Squeeze-Entry {'AN' if self._auto_squeeze else 'AUS'}")
+        self._save_runtime_state()
+        return self._auto_squeeze
+
+    def set_sr_autoclose(self, on: bool) -> bool:
+        """Automatischen S/R-Close an/aus (UI/Config). Rückgabe = neuer Zustand."""
+        self._auto_sr_close = bool(on)
+        log.info(f"Auto-S/R-Close {'AN' if self._auto_sr_close else 'AUS'}")
+        self._save_runtime_state()
+        return self._auto_sr_close
+
+    def set_sr_close_min_gain(self, value) -> float:
+        """Mindestgewinn (EUR) für den S/R-Auto-Close. 0/leer = aus."""
+        try:
+            v = max(0.0, float(value)) if value not in (None, "") else 0.0
+        except (TypeError, ValueError):
+            v = 0.0
+        self._sr_close_min_gain = v
+        log.info(f"S/R-Close-Mindestgewinn {('+' + format(v, '.2f')) if v > 0 else 'aus'}")
+        self._save_runtime_state()   # neustart-fest (Fix 2026-07-22 — war vergessen)
+        return self._sr_close_min_gain
+
+    def set_sltp(self, sl=None, tp=None) -> dict:
+        """Manuelles SL/TP der offenen Position setzen. **Deaktiviert das Trailing**
+        (User-Vorgabe) — sonst zöge es SL/TP beim nächsten Tick wieder um. Rückgabe
+        {ok, sl, tp} oder {ok:false, error}."""
+        err = self.trader.modify_sltp(sl, tp)
+        if err:
+            return {"ok": False, "error": err}
+        was = self.trail.deactivate()
+        if self.data.symbol:
+            self.trader.refresh(self.data.symbol)   # Snapshot mit neuen Werten
+        ps = self.trader.snapshot()
+        log.info(f"Manuelles SL/TP übernommen — Trailing "
+                 f"{'AUS (war an)' if was else 'war bereits aus'}")
+        return {"ok": True, "sl": ps.get("sl"), "tp": ps.get("tp"), "trail_was_on": was}
+
+    def _tu_loop(self):
+        while self._running:
+            try:
+                self.tu.fetch()
+            except Exception as e:
+                log.warning(f"_tu_loop: {e}")
+            self._sleep(_TU_S)
+
+    def _news_loop(self):
+        if self._sleep(6):     # kurz warten, bis MT5/Feeds bereit sind
+            return
+        while self._running:
+            try:
+                self.news.fetch()
+            except Exception as e:
+                log.warning(f"_news_loop: {e}")
+            if self._sleep(self._news_refresh_s):
+                return
+
+    def _agent_loop(self):
+        if self._sleep(_AGENT_WARMUP):
+            return
+        while self._running:
+            try:
+                if self.agent.auto_enabled and self.agent.is_configured():
+                    ok = self.agent.analyze()
+                    if ok:
+                        adv = self.agent.snapshot().get("advisory") or {}
+                        tf = adv.get("timeframe")
+                        if tf and self._tf_select == "agent":
+                            self._apply_wave_tf(tf)
+                        if self.agent.tg_push:
+                            self._agent_telegram()
+                    snaps = self.agent.snapshot()
+                    self._agent_fail_cnt = (0 if not snaps.get("error")
+                                            else min(self._agent_fail_cnt + 1, 5))
+            except Exception as e:
+                log.error(f"_agent_loop: {e}", exc_info=True)
+            # Backoff bei wiederholten Fehlern (Quota schonen)
+            interval = min(self.agent.interval_min * (2 ** self._agent_fail_cnt), 120)
+            if self._sleep(interval * 60):
+                return
+
+    def _sleep(self, secs: float) -> bool:
+        """Schläft `secs`, aber in 0.5-s-Schritten unterbrechbar.
+        Gibt True zurück, wenn die Engine in der Zwischenzeit gestoppt wurde."""
+        end = time.time() + secs
+        while time.time() < end:
+            if not self._running:
+                return True
+            time.sleep(min(0.5, max(0.0, end - time.time())))
+        return not self._running
+
+    # ── Analyse (entspricht _do_refresh_analysis im Widget) ────────────────
+    def _run_analysis(self):
+        s = self.data.snapshot()
+        if self._export_mql5:
+            self._write_levels_file()
+        self.trail.set_sr(s.get("sr"))
+        self.trail.set_trend_angle(s.get("angles", {}).get("M15", 90.0))
+
+        rec = self.wave.signal()
+        self._check_close_alert(rec)
+
+        # Empfehlung in die DB loggen (für Tages-Reviews; eigenes Throttling in
+        # history.log_recommendation → flutet nicht).
+        try:
+            adv = (self.agent.snapshot() or {}).get("advisory") or {}
+            self.history.log_recommendation(
+                signal=rec.get("signal"), score=float(rec.get("score") or 0.0),
+                conf_pct=int(rec.get("conf_pct") or 0),
+                angles=s.get("angles") or {},
+                reversal=("rev" if "REV" in (rec.get("setup") or "") else None),
+                ai_sentiment=adv.get("bias"), ai_confidence=adv.get("confidence"),
+                setup=rec.get("setup"), regime=None, rsi=s.get("rsi_m15"),
+                news_score=None)
+        except Exception as e:
+            log.warning(f"log_recommendation: {e}")
+
+        # Kontext für Trade-Logging (set_open_context beim Öffnen)
+        self._last_rec = {
+            "rec_signal":    rec.get("signal"),
+            "rec_score":     rec.get("score"),
+            "ai_sentiment":  None,
+            "ai_confidence": None,
+            "setup":         rec.get("setup"),
+            "regime":        None,
+            "rsi":           s.get("rsi_m15"),
+            "news_score":    None,
+        }
+
+    def _check_close_alert(self, rec: dict):
+        """Telegram-CLOSE-Alarm NUR bei Signal-Flip gegen die Position (echter
+        Wende-Grund). Max. 1×/5 min (`_CLOSE_ALERT_COOLDOWN_S`) — sonst spammt es.
+        Auch bei gesperrtem Handy. (S/R-Ziel-Alarm entfernt — stupste zum verfrühten
+        Gewinner-Kappen; den Gewinner führt das Trailing.)"""
+        ps = self.trader.snapshot()
+        ticket = ps.get("ticket")
+        if not ticket:
+            return   # keine Position → kein Alarm; Cooldown NICHT zurücksetzen
+                     # (ein kurz flackerndes Ticket darf den Cooldown nicht killen)
+        if ticket != self._close_alert_ticket:
+            self._close_alert_ticket = ticket
+            self._close_alert_ts = 0.0   # NUR bei echter neuer Position sofort melden
+        is_long = ps.get("order_type") == mt5.ORDER_TYPE_BUY
+        sig = rec.get("signal")
+        pnl = ps.get("pnl") or 0.0
+
+        # Close-Alarm NUR bei Signal-FLIP gegen die Position (echter Wende-Grund).
+        # Der frühere S/R-Ziel-Alarm wurde entfernt — er stupste zum verfrühten
+        # Kappen der Gewinner (Verlust-Schiefe); den Gewinner führt das Trailing.
+        reason = None
+        if pnl > 0:                     # Close-Alarm NUR bei Trade im Plus
+            if (is_long and sig == "SHORT") or (not is_long and sig == "LONG"):
+                reason = f"Empfehlung dreht auf {sig} ({rec.get('conf_pct')}%) — Gewinn sichern"
+
+        if not reason:
+            return
+        if time.time() - self._close_alert_ts < _CLOSE_ALERT_COOLDOWN_S:
+            return                      # innerhalb des 5-min-Cooldowns — kein Spam
+        self._close_alert_ts = time.time()
+        self._close_alert_count += 1
+        log.info(f"🔔 Flip-Close-Alarm #{self._close_alert_count}: "
+                 f"{self.data.symbol or ''} {'LONG' if is_long else 'SHORT'} "
+                 f"P&L {pnl:+.2f} — {reason}")
+        tg = self.cfg["telegram"]
+        if tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"):
+            try:
+                send_telegram(
+                    f"🔔 CLOSE-Signal\n"
+                    f"{self.data.symbol or ''} — offene "
+                    f"{'LONG' if is_long else 'SHORT'}-Position (P&L {pnl:+.2f})\n"
+                    f"{reason} — Schließen erwägen.",
+                    tg["bot_token"], tg["chat_id"])
+            except Exception as e:
+                log.warning(f"Close-Telegram: {e}")
+
+    def _apply_wave_tf(self, tf_label: str, source: str = ""):
+        """Wellen-Timeframe übernehmen (Wave/Elliott/Trailing koppeln).
+        Ein laufender Trade behält seinen eingefrorenen TF."""
+        tf_label = (tf_label or "").upper().strip()
+        const = _ATR_TF_MAP.get(tf_label)
+        if const is None or tf_label == self._wave_tf_label:
+            return
+        self._wave_tf_label = tf_label
+        self.wave.set_timeframe(const)
+        self.elliott.set_timeframe(const)
+        self.trail.set_atr_tf_override(const)
+        if self.data.symbol:
+            threading.Thread(
+                target=lambda: self.wave.refresh_market(self.data.symbol),
+                daemon=True).start()
+        log.info(f"Wellen-Timeframe → {tf_label}{(' ('+source+')') if source else ''}")
+        # Nur im Speicher merken — NICHT in die oil_widget_config.ini schreiben:
+        # save_config() schreibt die ganze (Secrets-)Datei neu und verliert dabei
+        # alle Kommentare; bei jedem Heuristik-Switch wäre das ständiger Churn.
+        # Beim Neustart wählt die Heuristik den TF binnen einer Minute ohnehin neu.
+        try:
+            self.cfg["trading"]["atr_tf"] = tf_label
+        except Exception:
+            pass
+
+    # ── TF-Heuristik: TF mit dem klarsten handelbaren EMA-Trend wählen ────────
+    _TF_BIAS = 0.10   # höhere TF leicht bevorzugen (Stabilität): weight = 1+rank*bias
+
+    def _choose_tf(self, sym: str):
+        """Bewertet die erlaubten TF nach Trendklarheit (|EMA12-EMA50|/ATR, nicht
+        überdehnt), bevorzugt höhere TF leicht und respektiert tf_min.
+        Gibt (beste_TF, scores) zurück."""
+        order = list(_ATR_TF_MAP.keys())          # M1..H1
+        min_rank = order.index(self._tf_min) if self._tf_min in order else order.index("M5")
+        max_rank = order.index(self._tf_max) if self._tf_max in order else len(order) - 1
+        scores: dict = {}
+        best_lbl, best_score = None, 0.0
+        with mt5_lock(timeout=2) as got:
+            if not got:
+                return None, scores
+            for rank, (lbl, tf) in enumerate(_ATR_TF_MAP.items()):
+                if rank < min_rank or rank > max_rank:   # außerhalb [tf_min … tf_max]
+                    scores[lbl] = 0.0; continue
+                r = mt5.copy_rates_from_pos(sym, tf, 0, 80)
+                if r is None or len(r) < _EMA_SLOW + 5:
+                    scores[lbl] = 0.0; continue
+                h = [float(b["high"]) for b in r]
+                l = [float(b["low"])  for b in r]
+                c = [float(b["close"]) for b in r]
+                atr = _wr_atr(h, l, c)
+                if not atr or atr <= 0:
+                    scores[lbl] = 0.0; continue
+                ef = _ema_last(c, _EMA_FAST); es = _ema_last(c, _EMA_SLOW)
+                sep     = abs(ef - es) / atr
+                stretch = abs(c[-1] - es) / atr
+                raw = sep if (sep >= _TREND_DEADBAND and stretch <= _STRETCH_MAX) else 0.0
+                score = raw * (1.0 + rank * self._TF_BIAS)   # höhere TF leicht bevorzugt
+                scores[lbl] = round(score, 2)
+                if score > best_score:
+                    best_score, best_lbl = score, lbl
+        return best_lbl, scores
+
+    def _tf_loop(self):
+        if self._tf_select != "heuristic":
+            return   # feste TF oder Agent steuert → keine Heuristik
+        if self._sleep(8):
+            return
+        while self._running:
+            try:
+                if self.data.symbol:
+                    best, scores = self._choose_tf(self.data.symbol)
+                    cur = self._wave_tf_label
+                    # Hysterese: nur wechseln, wenn klar besser (kein Flattern)
+                    if best and best != cur and \
+                       scores.get(best, 0) > scores.get(cur, 0) * 1.2:
+                        self._apply_wave_tf(best, source=f"Heuristik {scores}")
+            except Exception as e:
+                log.warning(f"_tf_loop: {e}")
+            if self._sleep(60):
+                return
+
+    def _load_report_state(self):
+        """Letzte Report-Versanddaten laden (überlebt Neustarts, s. __init__)."""
+        try:
+            import json
+            with open(self._report_state_path, encoding="utf-8") as f:
+                st = json.load(f)
+            self._daily_last_run = st.get("daily") or None
+            wk = st.get("weekly")
+            self._weekly_last_run = tuple(wk) if wk else None
+        except FileNotFoundError:
+            pass
+        except Exception as e:
+            log.warning(f"report_state laden: {e}")
+
+    def _save_report_state(self):
+        try:
+            import json
+            with open(self._report_state_path, "w", encoding="utf-8") as f:
+                json.dump({"daily": self._daily_last_run,
+                           "weekly": list(self._weekly_last_run)
+                                     if self._weekly_last_run else None}, f)
+        except Exception as e:
+            log.warning(f"report_state speichern: {e}")
+
+    def _load_runtime_state(self):
+        """UI-gesetzte Einstellungen laden (überleben Neustarts). Nur Keys, die
+        auch in der Datei stehen, überschreiben die ini-Defaults."""
+        try:
+            import json
+            with open(self._runtime_state_path, encoding="utf-8") as f:
+                st = json.load(f)
+            if "auto_squeeze" in st:
+                self._auto_squeeze = bool(st["auto_squeeze"])
+            if "auto_sr_close" in st:
+                self._auto_sr_close = bool(st["auto_sr_close"])
+            if "sr_close_min_gain" in st:
+                self._sr_close_min_gain = max(0.0, float(st["sr_close_min_gain"] or 0))
+            log.info(f"Runtime-State geladen: BRK={'AN' if self._auto_squeeze else 'aus'} · "
+                     f"S/R-Close={'AN' if self._auto_sr_close else 'aus'} · "
+                     f"MinGewinn={self._sr_close_min_gain:+.2f}")
+        except FileNotFoundError:
+            pass
+        except Exception as e:
+            log.warning(f"runtime_state laden: {e}")
+
+    def _save_runtime_state(self):
+        try:
+            import json, os
+            tmp = self._runtime_state_path + ".tmp"
+            with open(tmp, "w", encoding="utf-8") as f:
+                json.dump({"auto_squeeze": self._auto_squeeze,
+                           "auto_sr_close": self._auto_sr_close,
+                           "sr_close_min_gain": self._sr_close_min_gain}, f)
+            os.replace(tmp, self._runtime_state_path)
+        except Exception as e:
+            log.warning(f"runtime_state speichern: {e}")
+
+    def _load_emergency_state(self):
+        """Aktiven Notfall-Stop/Take-Profit + zugehöriges Ticket laden (überlebt
+        Neustarts). Wird in `_check_auto_close` nur angewandt, solange dasselbe
+        Ticket noch offen ist — sonst armiert die neue Position normal."""
+        try:
+            import json
+            with open(self._emergency_state_path, encoding="utf-8") as f:
+                st = json.load(f)
+            self._emergency_state_ticket = st.get("ticket")
+            self._emergency_state_loss = st.get("loss")
+            self._emergency_state_tp = st.get("tp")
+        except FileNotFoundError:
+            pass
+        except Exception as e:
+            log.warning(f"emergency_state laden: {e}")
+
+    def _save_emergency_state(self):
+        """Notfall-Stop/TP + Ticket atomar persistieren (bei Auto-Arm UND manuellem
+        Set) — damit ein Neustart/Wiedererkennen die Schwelle nicht auf die Automatik
+        zurücksetzt (Fix 2026-07-17)."""
+        try:
+            import json, os
+            tmp = self._emergency_state_path + ".tmp"
+            with open(tmp, "w", encoding="utf-8") as f:
+                json.dump({"ticket": self._last_pos_ticket,
+                           "loss": self._emergency_loss,
+                           "tp": self._takeprofit}, f)
+            os.replace(tmp, self._emergency_state_path)
+            self._emergency_state_ticket = self._last_pos_ticket
+            self._emergency_state_loss = self._emergency_loss
+            self._emergency_state_tp = self._takeprofit
+        except Exception as e:
+            log.warning(f"emergency_state speichern: {e}")
+
+    def _daily_loop(self):
+        """Holt 1×/Tag ab 07:30 Berlin die Tages-Level (Web-Recherche). Fail-safe:
+        bei Fehlschlag bis zu 3 Versuche, dann Tag abhaken (alte Level bleiben)."""
+        from datetime import datetime
+        from zoneinfo import ZoneInfo
+        berlin = ZoneInfo("Europe/Berlin")
+        if self._sleep(10):
+            return
+        while self._running:
+            try:
+                now = datetime.now(berlin)
+                today = now.date().isoformat()
+                due = (self._daily_last_run != today
+                       and (now.hour, now.minute) >= (7, 30))
+                if due and self.data.symbol:
+                    price = (self.data.snapshot() or {}).get("bid")
+                    if self.daily.is_configured() and price:
+                        self.daily.run(price)            # best-effort, fail-safe
+                    ok = self._send_daily_report()        # Telegram + E-Mail
+                    if ok or self._daily_fails >= 2:
+                        self._daily_last_run = today; self._daily_fails = 0
+                        self._save_report_state()
+                    else:
+                        self._daily_fails += 1
+                # B4-Wochenreport: montags ab 07:30, 1×/Woche (Track B)
+                wk = now.isocalendar()[:2]
+                if (now.weekday() == 0 and (now.hour, now.minute) >= (7, 30)
+                        and self._weekly_last_run != wk):
+                    if self._send_weekly_report():
+                        self._weekly_last_run = wk
+                        self._save_report_state()
+            except Exception as e:
+                log.warning(f"_daily_loop: {e}")
+            if self._sleep(120):
+                return
+
+    def _send_daily_report(self) -> bool:
+        """Tagesreport = heutige Web-Prognose + Vortags-Bilanz → Telegram +
+        E-Mail (trading@hocks.eu). True, wenn min. ein Kanal sendete. Fail-safe."""
+        from datetime import datetime, timedelta
+        try:
+            st = self.history.stats_overview("yesterday") or {}
+        except Exception as e:
+            log.warning(f"daily stats: {e}"); st = {}
+        n   = st.get("n_trades", 0); pnl = st.get("total_pnl", 0.0) or 0.0
+        wr  = st.get("winrate", 0.0) or 0.0; pf = st.get("profit_factor")
+        cur = (self.data.snapshot() or {}).get("currency") or "EUR"
+        yday = (datetime.now() - timedelta(days=1)).strftime("%d.%m.%Y")
+        pf_s = f"{pf:.2f}" if pf else "—"
+
+        dl = self.daily.snapshot()
+        if dl.get("support") is not None:
+            fc = (f"Bias {dl['bias']} · Support {dl['support']:.2f} · "
+                  f"Widerstand {dl['res_lo']:.2f}–{dl['res_hi']:.2f}\n{dl.get('summary','')}")
+        else:
+            fc = f"nicht verfügbar ({dl.get('error') or '—'})"
+
+        txt = (f"📊 Oil Tagesreport\n\n"
+               f"Heute — Prognose (Web):\n{fc}\n\n"
+               f"Gestern ({yday}):\n"
+               f"{n} Trades · WR {wr:.0f}% · PnL {pnl:+.2f} {cur} · PF {pf_s}")
+        html = (f"

Oil Tagesreport

" + f"

Heute — Prognose (Web)

{fc.replace(chr(10), '
')}

" + f"

Gestern ({yday})

{n} Trades · Trefferquote {wr:.0f} % · " + f"PnL {pnl:+.2f} {cur} · Profit-Faktor {pf_s}

" + f"

Prognose ist Dritt-Kontext " + f"(Web-Recherche), kein Handelssignal.

") + + sent = False + tg = self.cfg["telegram"] + if tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"): + try: + send_telegram(txt, tg["bot_token"], tg["chat_id"]); sent = True + except Exception as e: + log.warning(f"daily telegram: {e}") + if self.cfg.has_section("graph"): + report_to = (self.cfg["graph"].get("report_to") + or "trading@hocks.eu, axel@hocks.eu") + if send_graph_mail(self.cfg["graph"], f"Oil Trading Report {yday}", + html, report_to): + sent = True + log.info(f"Tagesreport gesendet: {sent}") + return sent + + def _send_weekly_report(self) -> bool: + """B4-Wochenreport (Track B) → E-Mail + Telegram. Hält Live-Stats gegen die + Backtest-Erwartung, flaggt Leckagen. Fail-safe: kein Kanal/Fehler → False.""" + from datetime import datetime + from html import escape + try: + from weekly_review import build_report + # M5 für den Squeeze-Monitor unter mt5_lock holen (kein shutdown!) und + # durchreichen — so ruft build_report selbst keine MT5-Funktion auf. + m5 = None + try: + with mt5_lock(timeout=5) as got: + if got and self.data.symbol: + rr = mt5.copy_rates_from_pos(self.data.symbol, mt5.TIMEFRAME_M5, 0, 30*288+30) + if rr is not None and len(rr): + pt = mt5.symbol_info(self.data.symbol).point + m5 = {"H": [float(b["high"]) for b in rr], "L": [float(b["low"]) for b in rr], + "C": [float(b["close"]) for b in rr], + "SP": [float(b["spread"]) * pt for b in rr]} + except Exception as e: + log.debug(f"weekly m5: {e}") + text = build_report(m5=m5) + except Exception as e: + log.warning(f"weekly build: {e}"); return False + kw = datetime.now().isocalendar()[1] + subject = f"Oil Wochenreport (B4) — KW{kw}" + html = ("

Oil Wochenreport (B4)

" + f"
"
+                f"{escape(text)}
" + "

Live-Stats vs. Backtest-Erwartung " + "(Track B/B4). Bei [!]: Ursache trennen (Regime/Disziplin/Sizing), nicht " + "am Signal drehen.

") + sent = False + tg = self.cfg["telegram"] + if tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"): + try: + send_telegram(f"📈 Wochenreport (B4) KW{kw}\n
{escape(text)}
", + tg["bot_token"], tg["chat_id"]); sent = True + except Exception as e: + log.warning(f"weekly telegram: {e}") + if self.cfg.has_section("graph"): + report_to = (self.cfg["graph"].get("report_to") + or "trading@hocks.eu, axel@hocks.eu") + try: + if send_graph_mail(self.cfg["graph"], subject, html, report_to): + sent = True + except Exception as e: + log.warning(f"weekly mail: {e}") + log.info(f"Wochenreport gesendet: {sent}") + return sent + + # ── Telegram-Callbacks (UI-frei) ─────────────────────────────────────── + def _on_trail_event(self, event: str, volume: float, price: float, profit: float): + tg = self.cfg["telegram"] + if not (tg.get("enabled", "false").lower() == "true" and tg.get("bot_token")): + return + try: + if event == "partial": + send_telegram( + f"💰 Teil-Exit (Partial TP)\n" + f"{self.data.symbol or ''} · {volume:.2f} L @ {price:.3f}\n" + f"Gewinn-Distanz {profit:+.3f} — Rest läuft mit Trailing weiter", + tg["bot_token"], tg["chat_id"]) + elif event == "adverse": + send_telegram( + f"🛑 Früh-Ausstieg\n" + f"{self.data.symbol or ''} · {volume:.2f} L @ {price:.3f}\n" + f"Trade lief {abs(profit):.3f} gegen den Einstieg — geschlossen", + tg["bot_token"], tg["chat_id"]) + except Exception as e: + log.warning(f"Trail-Event-Telegram: {e}") + + def _agent_telegram(self): + snap = self.agent.snapshot() + adv = snap.get("advisory") + if not adv: + return + tg = self.cfg["telegram"] + if tg.get("enabled", "false").lower() == "true" and tg.get("bot_token"): + try: + risks = "\n".join(f"• {r}" for r in adv.get("risks", [])) + send_telegram( + f"🧠 KI-Copilot: {adv['bias']} ({adv['confidence']}%)\n" + f"{adv['headline']}\n\n{adv['reasoning']}" + + (f"\n\nRisiken\n{risks}" if risks else ""), + tg["bot_token"], tg["chat_id"]) + except Exception as e: + log.warning(f"Agent-Telegram: {e}") + + # ══════════════════════════════════════════════════════════════════════ + # TRADE-AKTIONEN (für die Trade-Endpoints in server.py — Schritt 3) + # ══════════════════════════════════════════════════════════════════════ + def open_long(self) -> str | None: + return self._open(self.trader.open_long, "LONG") + + def open_short(self) -> str | None: + return self._open(self.trader.open_short, "SHORT") + + def _open(self, fn, want: str) -> str | None: + sym = self.data.symbol + if not sym: + return "Kein Symbol verbunden" + # KEIN harter Block mehr: Handel ohne passende Empfehlung ist erlaubt — + # das Frontend warnt + lässt bestätigen (User-Entscheidung). Hier nur + # protokollieren, falls gegen die aktuelle Empfehlung gehandelt wird. + sig = (self.wave.signal() or {}).get("signal", "WARTEN") + if sig != want: + log.info(f"[ORDER] {want} ohne passende Empfehlung (Signal={sig}) " + f"— vom User bestätigt, wird eröffnet") + self.trader.set_open_context(**self._last_rec) + err = fn(sym) + if err: + return err + # Trailing automatisch aktivieren (wie _after_trade im Widget) + if not self.trail.enabled: + self.trail.set_setup(self._last_rec.get("setup")) + self.trail.toggle(sym) + return None + + def close(self) -> str | None: + return self.trader.close(reason="manual") + + def toggle_trail(self) -> bool: + sym = self.data.symbol + if sym: + return self.trail.toggle(sym) + return self.trail.enabled + + def _sr_levels(self, market: dict) -> dict: + """Nächste Widerstandslinie ÜBER Preis (Short) und Unterstützung UNTER + Preis (Long) — kombiniert dynamische S/R-Levels + die [zones]-Config.""" + bid = market.get("bid") + if not bid: + return {"resistance": None, "support": None, + "resistances": [], "supports": []} + sr = market.get("sr") or {} + above, below = [], [] + for r in (sr.get("resistances") or []): + p = r.get("price") + if p: (above if p > bid else below).append(p) + for s in (sr.get("supports") or []): + p = s.get("price") + if p: (above if p > bid else below).append(p) + # Stehende Zonen aus der Config (beide Kanten als Linien) + try: + for z in parse_zones(self.cfg): + for edge in (z["lo"], z["hi"]): + (above if edge > bid else below).append(edge) + except Exception: + pass + # Tages-Level aus der Morgen-Recherche (Kontext) + try: + for lv in self.daily.zone_lines(): + (above if lv > bid else below).append(lv) + except Exception: + pass + # Offene Kurslücken (D1-Gaps) als Fill-Magnete (Kontext/Konfidenz) + try: + for lv in self.gaps.zone_lines(): + (above if lv > bid else below).append(lv) + except Exception: + pass + above = sorted(set(round(p, 3) for p in above)) + below = sorted(set(round(p, 3) for p in below), reverse=True) + return { + "resistance": above[0] if above else None, # nächster Widerstand (Short) + "support": below[0] if below else None, # nächste Unterstützung (Long) + "resistances": above[:3], + "supports": below[:3], + } + + def _draw_levels(self) -> dict: + """S/R-Linien fürs ZEICHNEN (PWA-Charts + MQL5-CSV): echte Kursstruktur = + rohe M5-Pivots (`wave.pb_levels`, identisch zum P(break)-/Auto-Close-System), + geclustert (~0,5×ATR). Zeichnet **nur die NÄCHSTE je Seite** (2 Linien total, + deckungsgleich mit den Dashboard-Werten `sr-res`/`sr-sup`) — sonst tauchten + beim Kurslauf die weiter entfernten Linien laufend auf/ab (User-Feedback + 2026-07-14). Bugfix davor: Linien kamen aus `_sr_levels` (stale [zones]-Config + + Tageslevel + Gaps) — Uralt-Level statt aktueller Struktur. Ergebnis wird + ~1 s gecacht (Konsistenz über alle Anzeigen + weniger Lock-Last).""" + now = time.time() + dc = self._draw_cache + if dc and (now - dc[0]) < 1.0: + return dc[1] + try: + ws = self.wave.snapshot() or {} + lv = ws.get("pb_levels") or {} + atr = (ws.get("pb_feats") or {}).get("atr") or 0.2 + bid = (self.data.snapshot() or {}).get("bid") + if not bid: + return {"res": [], "sup": []} + # Cluster-Toleranz 0,5×ATR: nahe Pivot-Zwillinge (z. B. 80,50 + 80,59) + # verschmelzen zu EINER Linie. + 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] + # Fix 2026-07-23 (User-Frage „Widerstand/Support-Retest von der falschen + # Seite"): Widerstand/Support werden NACH AKTUELLER KURSPOSITION gewählt + # (über/unter bid), NICHT mehr strikt nach Entstehungstyp (Swing-Hoch `ph` + # vs. Swing-Tief `pl`). Vorher blieb ein gebrochenes Level für IMMER im + # falschen Topf hängen: ein Swing-Tief, das der Kurs unterschritt und von + # UNTEN erneut ansteuerte, tauchte nie als Widerstand auf (kam nur aus `ph`); + # ein Swing-Hoch, das der Kurs überschritt und von OBEN erneut ansteuerte + # (Retest), tauchte nie als Support auf (kam nur aus `pl`) — 2 von 4 Retest- + # Varianten hatten dadurch nie eine Durchbruchwahrscheinlichkeit. + # ⚠ ph/pl bleiben GETRENNT geclustert (nicht vorher zusammenlegen!) — ein + # erster Versuch clusterte den kombinierten Rohpool direkt, was in dichten + # Zonen zu Ketten-Mega-Clustern führte (doppelte Punktdichte → die einfache + # Nachbar-Kette lief über >1×ATR durch). Erst NACH dem separaten Clustern + # (unveränderte Granularität wie vorher) werden die beiden fertigen + # Linien-Listen zusammengeführt und rein nach Position zu bid gewählt. + ph_c = cluster(lv.get("ph") or []) + pl_c = cluster(lv.get("pl") or []) + both = ph_c + pl_c + # NUR die nächste je Seite (2 Linien total = wie im Dashboard) + r = min((p for p in both if p > bid), default=None) + s = max((p for p in both if p < bid), default=None) + # Hysterese „klebrig" (Fix 2026-07-21): die gezeichnete Linie bleibt + # stehen, solange der Kurs sie nicht WIRKLICH durchbricht (bid > R+0,1×ATR + # bzw. bid < S−0,1×ATR). Ein neuer, näherer Pivot löst KEINEN Wechsel aus + # — genau das war die Zitter-Quelle (auf M5 bilden sich laufend neue Pivots). + # Ausnahme: ist die alte Linie weit weg (>1,5×ATR) UND ein deutlich näheres + # Level existiert, wird sie übernommen (sonst bleibt sie irrelevant hängen). + def hold(new, old, is_res): + if old is None: + return new + m = 0.10 * atr + broken = (bid > old + m) if is_res else (bid < old - m) + if broken: + return new # echter Durchbruch → nächste Linie (kann None sein) + if (new is not None and abs(old - bid) > 1.5 * atr + and abs(new - bid) < abs(old - bid) - 0.5 * atr): + return new # alte Linie veraltet/weit weg → näheres Level + return old # sonst: Linie HALTEN (kein Springen) + r = hold(r, self._draw_last.get("r"), True) + s = hold(s, self._draw_last.get("s"), False) + self._draw_last = {"r": r, "s": s} + out = {"res": [r] if r is not None else [], + "sup": [s] if s is not None else []} + self._draw_cache = (now, out) + return out + except Exception: + return {"res": [], "sup": []} + + def _dash_sr(self, market: dict) -> dict: + """S/R fürs Dashboard — GLEICHE Pivot-Quelle wie die Chart-Linien + (`_draw_levels`), damit Position-Kachel und Chart übereinstimmen.""" + dl = self._draw_levels() + res = dl.get("res") or []; sup = dl.get("sup") or [] + return {"resistance": (res[0] if res else None), + "support": (sup[0] if sup else None), + "resistances": res, "supports": sup} + + def _write_levels_file(self): + """Schreibt S/R als CSV nach `\\MQL5\\Files\\sr_levels.csv`, + damit ein MQL5-Indikator (mql5/SR_Levels.mq5) sie im Terminal zeichnet — das + Python-MT5-Paket kann keine Chart-Objekte erzeugen. Atomarer Write (.tmp→replace), + damit der Indikator nie eine halbe Datei liest.""" + import os + if self._mql5_files_dir is None: + try: + with mt5_lock(timeout=2) as got: + if got: + ti = mt5.terminal_info() + self._mql5_files_dir = os.path.join(ti.data_path, "MQL5", "Files") + except Exception: + self._mql5_files_dir = "" # einmal fehlgeschlagen → nicht endlos retryen + if not self._mql5_files_dir: + return + try: + dl = self._draw_levels() # echte Pivot-Struktur, nicht [zones]-Config + lines = [f"SYM;{self.data.symbol or ''}"] + for p in dl.get("res", []): lines.append(f"R;{p}") + for p in dl.get("sup", []): lines.append(f"S;{p}") + # Regressionskanal (M30) als Trendlinien-Anker → MQL5 zieht OBJ_TREND + # (Format CH;;t1;p1;t2;p2 mit Broker-Zeiten). + try: + cl = (self.structure.snapshot() or {}).get("channel_line") + if cl and cl.get("t1") and cl.get("t2"): + for kind, key in (("U", "upper"), ("M", "mid"), ("L", "lower")): + pts = cl.get(key) + if pts and len(pts) == 2: + lines.append(f"CH;{kind};{cl['t1']};{pts[0]};{cl['t2']};{pts[1]}") + except Exception: + pass + # Zonen NICHT mehr aus der (statischen) [zones]-Config — die dynamische + # Handels-Range zeichnet der Indikator selbst aus den Chart-Kerzen (TF-genau). + path = os.path.join(self._mql5_files_dir, "sr_levels.csv") + with open(path + ".tmp", "w", encoding="ascii", errors="ignore") as f: + f.write("\n".join(lines) + "\n") + os.replace(path + ".tmp", path) + except Exception as e: + log.debug(f"write_levels: {e}") + + def _verdict(self, market, wave_signal, wave_snap, tu, agent, elliott) -> dict: + """Gesamtempfehlung aus den Modulen für die Widget-Grafik. + + Headline = die **validierte Welle** (einziges Modul mit belegtem Edge); + die übrigen Module bilden einen gewichteten Konsens-Balken. REINE + ANZEIGE — ändert NICHT die Order-Logik (die hört nur auf die Welle). + Votes: +1 LONG, −1 SHORT, 0 neutral. + """ + def word(v): + return "auf" if v > 0 else "ab" if v < 0 else "flach" + + bid = (market or {}).get("bid") + votes = [] + + def add(name, vote, weight, detail): + votes.append({"name": name, "vote": int(vote), + "weight": weight, "detail": detail}) + + # 1) Welle — primär (validierter Edge) + wsig = (wave_signal or {}).get("signal") + wv = 1 if wsig == "LONG" else -1 if wsig == "SHORT" else 0 + add("Welle", wv, 3.0, f"{wsig or '—'} · {(wave_signal or {}).get('conf_pct', 0)}%") + + # 2/3) M30 / H1 — die EMA-Signale, die die Welle tatsächlich nutzt + m30 = int((wave_snap or {}).get("htf_trend", 0) or 0) + h1 = int((wave_snap or {}).get("h1_trend", 0) or 0) + add("M30", m30, 1.5, word(m30)) + add("H1", h1, 1.5, word(h1)) + + # 4) TradersUnion ENTFERNT aus dem Verdict-Konsens (User-Vorgabe 2026-07-06): + # lagt + nicht backtestbar + Standard-Indikator-Konfluenz = kein Edge. Bleibt + # nur noch reine Anzeige (Snapshot `tu`), zählt NICHT mehr in Bias/Agree. + + # 5) KI-Copilot — OHNE Aussage (kein advisory/bias, z. B. Agent aus) → Gewicht 0 + # (stimmt nicht mit ab, Chip bleibt sichtbar). Explizites "NEUTRAL" = echte + # Neutral-Stimme mit Gewicht. + adv = (agent or {}).get("advisory") or {} + ab = adv.get("bias") + av = 1 if ab == "LONG" else -1 if ab == "SHORT" else 0 + add("KI-Copilot", av, 1.0 if ab else 0.0, + f"{ab or '—'} · {adv.get('confidence', 0)}%") + + # 6) Elliott/FVG — nur stimmberechtigt, wenn ein Ziel existiert (sonst w=0) + ev, edet, ew = 0, "—", 0.0 + tgt = (elliott or {}).get("target") + if tgt is not None and bid: + ev = -1 if tgt < bid else 1 if tgt > bid else 0 + edet = f"{(elliott or {}).get('wave', '—')} → {tgt:.3f}" + ew = 1.0 + add("Elliott", ev, ew, edet) + + # 7) Squeeze-Breakout — Event-Signal: stimmt NUR bei `active` mit (dann 2,0, + # gemessen 2-Stichproben-positiv, unter der Welle=3,0). armed/keine → Gewicht 0 + # — sonst dämpft das fast immer neutrale Modul die Bias-Nadel dauerhaft + # (Logikfehler bis 2026-07-15: w=2,0 stand permanent im Nenner → Nadel ~×0,8). + sq = (wave_snap or {}).get("squeeze") or {} + sq_active = sq.get("state") == "active" + sqv = (1 if sq.get("dir") == "LONG" else -1) if sq_active else 0 + add("Squeeze", sqv, 2.0 if sq_active else 0.0, + f"{sq.get('state') or '—'}" + (f" {sq.get('dir')}" if sq.get("dir") else "")) + + # Bias nur über stimmberechtigte Module (weight>0); agree/total = wie viele + # der ANDEREN stimmberechtigten Module die Wellen-Richtung teilen (die Welle + # zählte sich vorher selbst mit — „3/6 für LONG" war geschönt). + wsum = sum(v["weight"] for v in votes) or 1.0 + bias = sum(v["weight"] * v["vote"] for v in votes) / wsum + others = [v for v in votes if v["name"] != "Welle" and v["weight"] > 0] + agree = sum(1 for v in others if wv != 0 and v["vote"] == wv) + return { + "headline": wsig, "conf": (wave_signal or {}).get("conf_pct", 0), + "tf": (wave_snap or {}).get("tf"), # Zeitebene, für die die Empfehlung gilt + "bias": round(bias, 3), "agree": agree, "total": len(others), + "votes": votes, + } + + # ══════════════════════════════════════════════════════════════════════ + # SNAPSHOT (ein JSON-fähiger Zustand für /api/snapshot + WebSocket) + # ══════════════════════════════════════════════════════════════════════ + _SNAP_TTL_S = 0.8 # geteilter Snapshot gilt so lange als frisch + + def snapshot_cached(self) -> dict: + """Teilt EINEN frisch berechneten Snapshot über alle WS-Clients/HTTP- + Aufrufe (TTL ~0,8 s). Spart SQLite/Verdict-Last bei mehreren Geräten — + unabhängig von der Client-Anzahl wird nur ~1×/TTL gerechnet.""" + now = time.time() + with self._snap_lock: + if self._snap_cache and (now - self._snap_cache_ts) < self._SNAP_TTL_S: + return self._snap_cache + snap = self.snapshot() # teuer → außerhalb des Locks rechnen + with self._snap_lock: + self._snap_cache = snap + self._snap_cache_ts = time.time() + return snap + + def _fetch_m5_monitor(self, days: int = 30): + """M5-Bars für den Squeeze-Monitor (unter mt5_lock, aus der Server-Instanz).""" + import MetaTrader5 as mt5 + from core.mt5_utils import mt5_lock + sym = self.data.symbol + if not sym: + return None + with mt5_lock(timeout=5) as got: + if not got: + return None + bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, days * 288 + 30) + si = mt5.symbol_info(sym) + point = si.point if si else 0.01 + if bars is None or len(bars) == 0: + return None + return {"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 _evaluate_pbreak_predictions(self, window_min: int = 60): + """Wertet offene Abprall/Durchbruch-Vorhersagen anhand von `candles_m1` aus — + DIESELBE Definition wie beim Modell-Training (`_BRK_ATR=0,5`/`_BRK_W=12` M5- + Bars = 60 min in `backtest_srclose_prob.py`): erreicht der Kurs zuerst + `confirm_price` → 'break', zuerst `reject_price` → 'bounce', keins von beiden + binnen 60 min → 'bounce' (Timeout, wie im Training). Self-throttled (~5 min), + aus `_trend_loop`. `candles_m1.time` ist rohe Broker-Zeit — Offset nötig.""" + now = time.time() + if now - getattr(self, "_pbreak_eval_ts", 0.0) < 300: + return + self._pbreak_eval_ts = now + try: + import sqlite3 + off = self.trader._broker_offset_s() + con = sqlite3.connect(HISTORY_DB_FILE, timeout=5.0) + con.row_factory = sqlite3.Row + pend = con.execute( + "SELECT id, ts, direction, predicted, confirm_price, reject_price " + "FROM pbreak_predictions WHERE outcome IS NULL" + ).fetchall() + for r in pend: + d = 1 if r["direction"] == "LONG" else -1 + t0_b = r["ts"] + off # Vorhersage-Zeit in Broker-Zeit + t1_b = t0_b + window_min * 60 + bars = con.execute( + "SELECT h, l, time FROM candles_m1 WHERE time > ? AND time <= ? " + "ORDER BY time ASC", (t0_b, t1_b)).fetchall() + outcome = None + for b in bars: + hit_confirm = (b["h"] >= r["confirm_price"]) if d > 0 else (b["l"] <= r["confirm_price"]) + hit_reject = (b["l"] <= r["reject_price"]) if d > 0 else (b["h"] >= r["reject_price"]) + if hit_confirm: + outcome = "break"; break + if hit_reject: + outcome = "bounce"; break + if outcome is None and time.time() >= (r["ts"] + window_min * 60): + outcome = "bounce" # Timeout, wie im Training (brk=False) + if outcome: + correct = 1 if r["predicted"] == outcome else 0 + con.execute( + "UPDATE pbreak_predictions SET outcome=?, outcome_ts=?, correct=? WHERE id=?", + (outcome, int(time.time()), correct, r["id"])) + con.commit(); con.close() + except Exception as e: + log.debug(f"pbreak_predictions auswerten: {e}") + + def squeeze_monitor(self) -> dict: + """Auto-Squeeze-Monitor (B4) für das Dashboard: (1) LIVE-Trades (echt, DB — + das Gesamtsystem inkl. realer Exits) und (2) die MECHANISCHE Neuberechnung + der Squeeze-Regel auf ~30 Tagen M5 (Entry-Edge ISOLIERT, Backtest-Exit) gegen + die Erwartung (ØR +0,14…+0,23 · PF>1). Cache TTL 60 s (M5-Fetch+Scan).""" + import sqlite3 + now = time.time() + if self._sqmon_cache and now - self._sqmon_cache_ts < 60: + return self._sqmon_cache + exp = {"or_lo": 0.14, "or_hi": 0.23} + + # ── 1) Live-Trades (echt) — SQUEEZE_* aus der DB ────────────────────── + live = {"n": 0} + try: + con = sqlite3.connect(HISTORY_DB_FILE); con.row_factory = sqlite3.Row + rows = [r for r in con.execute( + "SELECT direction, entry_price, exit_price, sl_at_entry, pnl, entry_time " + "FROM trades WHERE setup LIKE 'SQUEEZE%' AND exit_time IS NOT NULL")] + con.close() + pnls = [r["pnl"] for r in rows if r["pnl"] is not None] + if pnls: + n = len(pnls); wins = [p for p in pnls if p > 0]; losses = [p for p in pnls if p < 0] + Rs = [] + for r in rows: # ØR-Näherung via SL-Distanz (SL≈2×ATR) + d = 1 if r["direction"] in ("BUY", "LONG") else -1 + sld = abs((r["entry_price"] or 0) - (r["sl_at_entry"] or 0)) + if sld > 0 and r["exit_price"] and r["entry_price"]: + Rs.append(2.0 * (r["exit_price"] - r["entry_price"]) * d / sld) + day, night = [], [] + for r in rows: + if r["pnl"] is None: continue + h = (datetime.fromtimestamp(r["entry_time"], _BERLIN).hour + if r["entry_time"] else 12) + (night if h in _SQUEEZE_NIGHT else day).append(r["pnl"]) + live = { + "n": n, "wr": round(100 * len(wins) / n), + "avg_win": round(sum(wins) / len(wins), 2) if wins else 0.0, + "avg_loss": round(sum(losses) / len(losses), 2) if losses else 0.0, + "ratio": (round((sum(wins) / len(wins)) / abs(sum(losses) / len(losses)), 2) + if wins and losses else None), + "sum_eur": round(sum(pnls), 2), + "oR": round(sum(Rs) / len(Rs), 3) if Rs else None, + "day": {"n": len(day), "sum": round(sum(day), 2)}, + "night": {"n": len(night), "sum": round(sum(night), 2)}, + } + except Exception as e: + live = {"n": 0, "error": str(e)[:100]} + + # ── 2) Mechanik (B4) — Squeeze-Regel neu gerechnet, Entry-Edge isoliert ── + mech = {"error": "keine M5-Daten"} + try: + m5 = self._fetch_m5_monitor() + if m5 and len(m5["C"]) >= 300: + import backtest_breakout_squeeze as BQ + from core.wave_rec import _SQ_N, _SQ_MULT, _SQ_K + BQ._N = _SQ_N; BQ._K = _SQ_K + H, L, C, SP = m5["H"], m5["L"], m5["C"], m5["SP"] + A = BQ._atr_series(H, L, C) + sq, _ = BQ.scan(H, L, C, A, SP, 1.0, 0, len(C), _SQ_MULT) + n = len(sq) + if n: + w = sum(1 for x in sq if x > 0); s = sum(sq) + up = sum(x for x in sq if x > 0); dn = -sum(x for x in sq if x < 0) + mech = {"n": n, "wr": round(100 * w / n), "oR": round(s / n, 3), + "pf": round(up / dn, 2) if dn > 0 else 99.9, "sum": round(s)} + else: + mech = {"n": 0} + except Exception as e: + mech = {"error": str(e)[:100]} + + # ── 3) Verdict aus der Mechanik (isoliert = apples-to-apples zum Backtest) ── + verdict = "watch" + if mech.get("n", 0) >= 6 and mech.get("oR") is not None: + if mech["oR"] > 0 and mech.get("pf", 0) > 1: + verdict = "ontrack" if mech["oR"] >= exp["or_lo"] else "weak" + else: + verdict = "drift" + + res = {"live": live, "mech": mech, "expect": exp, "verdict": verdict, + "params": {"box": None}} + try: + from core.wave_rec import _SQ_N, _SQ_MULT, _SQ_K + res["params"] = {"box": _SQ_N, "k": _SQ_K, "mult": _SQ_MULT} + except Exception: + pass + self._sqmon_cache = res; self._sqmon_cache_ts = now + return res + + def _compact_stats(self, period: str) -> dict: + """Kompakte Kennzahlen für die Live-Statistik-Kachel (WR, PF, Ø-Gewinn/ + -Verlust, Verhältnis, Trades, PnL) — aus `stats_overview`.""" + try: + ov = self.history.stats_overview(period) or {} + except Exception: + return {} + if ov.get("error"): + return {} + aw = ov.get("avg_win", 0.0) or 0.0 + al = ov.get("avg_loss", 0.0) or 0.0 + pf = ov.get("profit_factor") + return { + "n": ov.get("n_trades", 0) or 0, + "wr": round(ov.get("winrate", 0.0) or 0.0), + "pf": round(pf, 2) if pf is not None else None, + "avg_win": round(aw, 2), + "avg_loss": round(al, 2), + "ratio": round(aw / abs(al), 2) if al < 0 else None, + "pnl": round(ov.get("total_pnl", 0.0) or 0.0, 2), + } + + def snapshot(self) -> dict: + try: + last_trades = self.history.last_closed_trades(5) + except Exception as e: + log.warning(f"last_closed_trades: {e}") + last_trades = [] + market = self.data.snapshot() + wave_signal = self.wave.signal() + wave_snap = self.wave.snapshot() + elliott = self.elliott.snapshot() + tu = self.tu.snapshot() + agent = self.agent.snapshot() + position = self.trader.snapshot() + # Tages-P/L: realisiert (heute geschlossene Trades) + offene Position + try: + day_realized = float((self.history.stats_overview("today") or {}) + .get("total_pnl") or 0.0) + except Exception as e: + log.warning(f"day stats: {e}"); day_realized = 0.0 + open_pnl = float(position.get("pnl") or 0.0) + day_pl = {"realized": round(day_realized, 2), "open": round(open_pnl, 2), + "total": round(day_realized + open_pnl, 2)} + sr_close_hint = self._sr_close_hint(market, position, wave_snap) + return { + "ts": time.time(), + "connected": self._connected, + "running": self._running, + "error": self._error, + "symbol": self.data.symbol, + "wave_tf": self._wave_tf_label, + "min_conf": _MIN_CONF_DISPLAY, + "auth_required": self.require_token, + "market": market, + # Dashboard-S/R = GLEICHE Quelle wie die Chart-Linien (`_draw_levels` = + # rohe M5-Pivots), damit Kachel und Chart nicht mehr auseinanderlaufen. + # `_sr_levels` (Misch-Set) bleibt intern für Strategie/Konfidenz. + "sr_levels": self._dash_sr(market), + "session": session_state(), + "position": position, + "day_pl": day_pl, + "emergency_loss": self._emergency_loss, + "emergency_remember": self._emergency_remember, + "emergency_pct": self._emergency_pct, + "emergency_margin_pct": self._emergency_margin_pct, + "wht_pct": self._wht_pct, + "takeprofit": self._takeprofit, + "takeprofit_remember": self._takeprofit_remember, + "trail": self.trail.snapshot(), + "wave_signal": wave_signal, + "wave": wave_snap, + "elliott": elliott, + "tu": tu, + "agent": agent, + "verdict": self._verdict(market, wave_signal, wave_snap, + tu, agent, elliott), + "daily_levels": self.daily.snapshot(), + "gaps": self.gaps.snapshot(), + "structure": self.structure.snapshot(), + "stats_compact": {"week": self._compact_stats("week"), + "all": self._compact_stats("all")}, + "close_alert_count": self._close_alert_count, + "news_storm": self.news.storm_state(), + "sr_close_hint": sr_close_hint, + "auto_sr_close": self._auto_sr_close, + "sr_close_min_gain": self._sr_close_min_gain, + "sr_close_pbreak": self._sr_close_pbreak, + "sr_close_count": self._sr_close_count, + "auto_squeeze": self._auto_squeeze, + "squeeze_entry_count": self._squeeze_entry_count, + "last_trades": last_trades, + } + + def _sr_close_hint(self, market, position, wave_snap) -> dict | None: + """S/R-Close-Empfehlung mit kalibriertem P(Durchbruch)-Modell (gemessen + `backtest_srclose_prob.py`, Echtkosten, 2 Hälften): Kurs AM gegenüberliegenden + Level → P(break) aus `_p_break` schätzen; ist P < `sr_close_pbreak` (Default + 0,60 = User-Regel „laufen lassen wenn >60 %"), prallt das Level wahrscheinlich + ab → Close schlägt Laufenlassen deutlich (+6000/+7600 R vs. reines Trailing). + Liefert IMMER die P(break)-Zahl (auch wenn ≥Schwelle → `close=False`) für die + Live-Anzeige.""" + try: + if not position.get("ticket"): + self._pb_state = None + return None + is_long = position.get("order_type") == 0 + bid = (market or {}).get("bid") + # Features FEST aus M5 (`wave.pb_feats`) — Modell wurde auf M5 trainiert; + # die Wave-TF-Werte (bis M30) verschoben die Skala → P falsch (Bugfix). + pf = (wave_snap or {}).get("pb_feats") or {} + atr = pf.get("atr") + mom6 = pf.get("mom6") + mom3 = pf.get("mom3") + ediff = pf.get("ema_diff") + entry = position.get("entry_price") or bid + # Level = EXAKT die gezeichnete Linie (`_draw_levels`, geclustert + + # Hysterese) — für LONG die nächste Resistance, für SHORT die nächste + # Support. So referenziert die Close-Meldung dasselbe Level, das im + # Chart/Dashboard steht (vorher rohe pb_levels → Meldung ≠ Linie, + # User-Feedback 2026-07-15). P(break) auf M5-Features unverändert. + dl = self._draw_levels() + if is_long: + level = (dl.get("res") or [None])[0] + else: + level = (dl.get("sup") or [None])[0] + if not (bid and atr and level) or mom6 is None or mom3 is None or ediff is None: + self._pb_state = None + return None + d = 1 if is_long else -1 + gap = (level - bid) * d # Abstand zum Level (in Preis) + if gap > 0.6 * atr: # weit weg → gar kein Hinweis + self._pb_state = None + return None + wt = 1.0 if ediff * d > 0 else 0.0 + dist = abs(level - entry) / atr + p_raw = _p_break(mom6 * d, mom3 * d, wt, dist) + # ── Anti-Flattern (gemessen `analyze_pbreak_flicker.py`) ───────────── + # (1) P über ~60 s glätten (Zeit-EMA) — mom3 der laufenden M5-Kerze ließ + # P sonst 7–15 Pp je Bar springen. Reset bei Level-Wechsel. + # (2) Hysterese ±5 Pp um die Schwelle: close↔laufen wechselt erst, wenn + # das geglättete P die Schwelle klar durchstößt (kein Flip-Flop). + import math as _m + now = time.time() + st = self._pb_state + if not st or abs(st["level"] - level) > 1e-9: + st = {"level": level, "p": p_raw, "ts": now, "zone": None} + else: + a = 1.0 - _m.exp(-max(0.0, now - st["ts"]) / 60.0) + st["p"] += a * (p_raw - st["p"]) + st["ts"] = now + p_s = st["p"] + thr = self._sr_close_pbreak + zone = st.get("zone") + if zone is None: + zone = "close" if p_s < thr else "run" + elif zone == "close" and p_s >= thr + 0.05: + zone = "run" + elif zone == "run" and p_s < thr - 0.05: + zone = "close" + st["zone"] = zone + at_level = gap <= 0.15 * atr # Auslöse-Fenster (wie gemessen: Touch) + # Bruch-BESTÄTIGUNGS-Preis (Fix 2026-07-22, User-Frage „genauer berechnen"): + # dieselbe Definition wie beim TRAINING des Modells (`_BRK_ATR=0,5`, + # `backtest_srclose_prob.py`/`analyze_srwaves.py`) — ein Kurs, der die reine + # Levelzahl nur um wenige Cent überschreitet (Docht/Rauschen), zählt NICHT + # als „Durchbruch"; erst level±0,5×ATR gilt als bestätigt. Reale Analyse + # 2026-07-22: Trade bei Level 85,943 geschlossen, Kurs wischte kurz auf + # 85,994 (< Bestätigung 86,017), fiel danach zurück → laut Modell-eigener + # Definition eine ABLEHNUNG, kein Bruch — die Vorhersage war korrekt. + confirm = round(level + d * 0.5 * atr, 3) # Richtung Durchbruch + reject = round(level - d * 0.5 * atr, 3) # Richtung Abprall (Gegenbestätigung) + # Prognose-Logging (User-Wunsch 2026-07-23): EINMAL je frischem Touch + # (at_level False→True) die aktuelle Abprall/Durchbruch-Vorhersage + # protokollieren. Auswertung separat gegen `candles_m1` + # (`_evaluate_pbreak_predictions`, periodisch im Trend-Loop) — dieselbe + # confirm/reject-Definition wie beim Modell-Training, also fair verglichen. + if at_level and not st.get("was_at_level"): + try: + self.history.log_pbreak_prediction( + symbol=self.data.symbol or "", + direction=("LONG" if is_long else "SHORT"), + level=level, p_break=round(100 * p_s, 1), + predicted=("bounce" if zone == "close" else "break"), + price_at_pred=bid, confirm_price=confirm, + reject_price=reject, atr=atr) + except Exception as le: + log.debug(f"pbreak_prediction log: {le}") + st["was_at_level"] = at_level + self._pb_state = st + return {"level": round(level, 3), "p_break": round(100 * p_s), + "mom": round(mom3 * d, 2), "at_level": at_level, + "gap_atr": round(gap / atr, 2), "confirm": confirm, + # Auto-Close NUR exakt am Level; die Anzeige (0,15–0,6×ATR davor) + # ist reine Vorwarnung mit geglättetem P(break) + "close": at_level and zone == "close"} + except Exception as e: + log.debug(f"sr_close_hint: {e}") + return None diff --git a/core/gaps.py b/core/gaps.py new file mode 100644 index 0000000..09d801d --- /dev/null +++ b/core/gaps.py @@ -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} diff --git a/core/history.py b/core/history.py new file mode 100644 index 0000000..fb725ba --- /dev/null +++ b/core/history.py @@ -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] diff --git a/core/logger.py b/core/logger.py new file mode 100644 index 0000000..f306e3f --- /dev/null +++ b/core/logger.py @@ -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) diff --git a/core/mailer.py b/core/mailer.py new file mode 100644 index 0000000..6293a31 --- /dev/null +++ b/core/mailer.py @@ -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 diff --git a/core/market_hours.py b/core/market_hours.py new file mode 100644 index 0000000..7e3993f --- /dev/null +++ b/core/market_hours.py @@ -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, + } diff --git a/core/mt5_utils.py b/core/mt5_utils.py new file mode 100644 index 0000000..516c624 --- /dev/null +++ b/core/mt5_utils.py @@ -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 diff --git a/core/mt5data.py b/core/mt5data.py new file mode 100644 index 0000000..5e7c1fc --- /dev/null +++ b/core/mt5data.py @@ -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() diff --git a/core/news.py b/core/news.py new file mode 100644 index 0000000..bc1f23c --- /dev/null +++ b/core/news.py @@ -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']+href="(https?://oilprice\.com/' + r'(?:Energy|Latest-Energy-News)/[^"#?]{10,})"[^>]*>' + r'\s*([^<\n]{25,200})\s*', + 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)} diff --git a/core/notify.py b/core/notify.py new file mode 100644 index 0000000..1c11a90 --- /dev/null +++ b/core/notify.py @@ -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} {dir_str}{sym_str}"] + 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}" diff --git a/core/structure.py b/core/structure.py new file mode 100644 index 0000000..762731d --- /dev/null +++ b/core/structure.py @@ -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) diff --git a/core/trader.py b/core/trader.py new file mode 100644 index 0000000..65a497c --- /dev/null +++ b/core/trader.py @@ -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, + ) diff --git a/core/trailing.py b/core/trailing.py new file mode 100644 index 0000000..8999c41 --- /dev/null +++ b/core/trailing.py @@ -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, + } diff --git a/core/tu_rating.py b/core/tu_rating.py new file mode 100644 index 0000000..a3be867 --- /dev/null +++ b/core/tu_rating.py @@ -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} diff --git a/core/wave_rec.py b/core/wave_rec.py new file mode 100644 index 0000000..c8661ef --- /dev/null +++ b/core/wave_rec.py @@ -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 diff --git a/docs/exit-simulation.md b/docs/exit-simulation.md new file mode 100644 index 0000000..ca760a6 --- /dev/null +++ b/docs/exit-simulation.md @@ -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) diff --git a/docs/gesamtempfehlung.md b/docs/gesamtempfehlung.md new file mode 100644 index 0000000..5d13c8e --- /dev/null +++ b/docs/gesamtempfehlung.md @@ -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`. diff --git a/docs/risiko-management.md b/docs/risiko-management.md new file mode 100644 index 0000000..185e2dc --- /dev/null +++ b/docs/risiko-management.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. diff --git a/find_gaps.py b/find_gaps.py new file mode 100644 index 0000000..3e76907 --- /dev/null +++ b/find_gaps.py @@ -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() diff --git a/mql5/SR_Levels.mq5 b/mql5/SR_Levels.mq5 new file mode 100644 index 0000000..bdc56df --- /dev/null +++ b/mql5/SR_Levels.mq5 @@ -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 \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; — 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;;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(); +} +//+------------------------------------------------------------------+ diff --git a/oil_widget_config.ini.example b/oil_widget_config.ini.example new file mode 100644 index 0000000..117a2c1 --- /dev/null +++ b/oil_widget_config.ini.example @@ -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 diff --git a/restart_server.bat b/restart_server.bat new file mode 100644 index 0000000..a882b4b --- /dev/null +++ b/restart_server.bat @@ -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 diff --git a/scripts/daily_summary.py b/scripts/daily_summary.py new file mode 100644 index 0000000..b5521b8 --- /dev/null +++ b/scripts/daily_summary.py @@ -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"

Keine Trades am {target_date.strftime('%d.%m.%Y')}.

" + + 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""" + + {s} + {d['n']} + {wr_s:.0f}% + {fmt_pnl(d['pnl'])} + """ + + 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""" + +

+ Oil Trading — {target_date.strftime('%d.%m.%Y')} +

+ + + + + + + + + + + + + + +
Trades{total}
Win Rate{wr:.0f}% ({wins}/{total})
PnL{fmt_pnl(pnl_sum)}
+ +

Pro Setup

+ + + + + + + + {rows} +
SetupNWRPnL
+ + + + + + + + + + + + +
Bester Trade{fmt_pnl(best['pnl'])}{best.get('setup','—')} {best_time}
Schlechtester{fmt_pnl(worst['pnl'])}{worst.get('setup','—')} {worst_time}
+ +""" + 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)) diff --git a/scripts/send_daily_report.ps1 b/scripts/send_daily_report.ps1 new file mode 100644 index 0000000..89caab8 --- /dev/null +++ b/scripts/send_daily_report.ps1 @@ -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 () als Content ankommen → leere Mail. +$htmlBody = (& $python $pyScript $yesterday 2>&1) -join "`n" + +if ([string]::IsNullOrWhiteSpace($htmlBody) -or $htmlBody -match "Traceback|Error:") { + $htmlBody = "

Fehler beim Generieren des Reports für $yesterday.

$htmlBody
" +} + +$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" diff --git a/server.py b/server.py new file mode 100644 index 0000000..af4f01d --- /dev/null +++ b/server.py @@ -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) diff --git a/start_server.bat b/start_server.bat new file mode 100644 index 0000000..7697601 --- /dev/null +++ b/start_server.bat @@ -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 diff --git a/start_server_hidden.vbs b/start_server_hidden.vbs new file mode 100644 index 0000000..3d9bd7e --- /dev/null +++ b/start_server_hidden.vbs @@ -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 diff --git a/stop_server.bat b/stop_server.bat new file mode 100644 index 0000000..bb0a60a --- /dev/null +++ b/stop_server.bat @@ -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 diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..e7443fb --- /dev/null +++ b/web/app.js @@ -0,0 +1,1167 @@ +"use strict"; +// ── Live-Anbindung an server.py via WebSocket (Fallback: Polling) ────────── +const $ = (id) => document.getElementById(id); + +function fmt(v, d = 3) { + return (v === null || v === undefined || isNaN(v)) ? "—" : Number(v).toFixed(d); +} +function curSym(m) { + const c = m && m.currency; + return c === "EUR" ? "€" : c === "USD" ? "$" : (c ? c + " " : "$"); +} + +// ── Akustischer CLOSE-Alarm ───────────────────────────────────────────────── +// Web-Audio (kein Sound-File). Mobile entsperrt Audio erst nach der ersten +// Nutzer-Interaktion → wir starten/reaktivieren den Context bei jedem Tap. +let _audioCtx = null, _lastBeep = 0; +function _unlockAudio() { + try { + if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + if (_audioCtx.state === "suspended") _audioCtx.resume(); + } catch (e) {} +} +window.addEventListener("pointerdown", _unlockAudio); +window.addEventListener("touchstart", _unlockAudio); + +// Globaler Ton-Schalter (Mute-Button oben rechts; überlebt Reloads via localStorage) +let _muted = localStorage.getItem("oil_muted") === "1"; +function _tone(freq, startOffset, dur) { + if (_muted) return; + const o = _audioCtx.createOscillator(), g = _audioCtx.createGain(); + o.type = "square"; o.frequency.value = freq; + o.connect(g); g.connect(_audioCtx.destination); + const t = _audioCtx.currentTime + startOffset; + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(0.35, t + 0.02); + g.gain.exponentialRampToValueAtTime(0.0001, t + dur); + o.start(t); o.stop(t + dur + 0.03); +} +function beepCloseAlert() { + if (_muted) return; + if (!_audioCtx || _audioCtx.state !== "running") return; // erst nach 1. Tap aktiv + const now = Date.now(); + if (now - _lastBeep < 4000) return; // max. alle 4 s + _lastBeep = now; + _tone(880, 0, 0.18); // Zwei-Ton-Alarm (hoch→tief) + _tone(620, 0.22, 0.20); +} +// Ton bei Empfehlung: LONG = aufsteigend, SHORT = absteigend (vom Close-Alarm +// unterscheidbar). Sofort beim Signal-Wechsel + WIEDERHOLUNG alle 30 s, solange +// der Button blinkt (Empfehlung aktiv & flat) — einmaliger Piep war überhörbar. +const _REC_REBEEP_MS = 30000; +let _lastRecBeep = 0, _lastRecSignal = null, _audioHintShown = false, _lastSrCloseCount = null; +function beepRecommendation(sig, isNew) { + if (_muted) return; // auch kein Sperr-Toast + if (!_audioCtx || _audioCtx.state !== "running") { + // Audio noch nicht freigeschaltet (Mobile braucht 1× Tippen) → 1× erklären + if (isNew && !_audioHintShown) { + _audioHintShown = true; + toast("🔇 Ton gesperrt — einmal auf die Seite tippen aktiviert ihn", false); + } + return; + } + const now = Date.now(); + if (now - _lastRecBeep < (isNew ? 3000 : _REC_REBEEP_MS)) return; + _lastRecBeep = now; + if (sig === "LONG") { _tone(520, 0, 0.16); _tone(780, 0.18, 0.20); } // ↑ + else { _tone(780, 0, 0.16); _tone(520, 0.18, 0.20); } // ↓ +} + +// ── Gesamtempfehlung: Konfidenz-Ring + Bias-Balken + Modul-Chips ─────────── +function renderVerdict(v) { + const sig = v.headline || "—"; + const cls = sig === "LONG" ? "long" : sig === "SHORT" ? "short" : ""; + const arr = sig === "LONG" ? "▲" : sig === "SHORT" ? "▼" : "◌"; + $("vd-signal").className = "vd-signal " + cls; + // Zeitebene, für die die Empfehlung gilt (M1/M5/M15/M30/H1) + $("vd-signal").textContent = `${arr} ${sig}` + (v.tf ? ` · ${v.tf}` : ""); + + // Konfidenz-Ring (conic-gradient bis conf%, Farbe nach Richtung) + const conf = v.conf || 0; + const col = sig === "LONG" ? "var(--up)" : sig === "SHORT" ? "var(--down)" : "var(--dim)"; + const deg = Math.max(0, Math.min(100, conf)) * 3.6; + $("vd-ring").style.background = + `conic-gradient(${col} ${deg}deg, #21262d ${deg}deg)`; + $("vd-conf").textContent = conf ? conf + "%" : "—"; + + // Konsens-Text + Bias-Nadel (-1..+1 → 0..100 %) + const total = v.total || 0, agree = v.agree || 0; + const bias = typeof v.bias === "number" ? v.bias : 0; + const strength = Math.abs(bias) >= 0.5 ? "stark" : Math.abs(bias) >= 0.2 ? "leicht" : "gemischt"; + const dir = bias > 0.05 ? "LONG" : bias < -0.05 ? "SHORT" : "neutral"; + $("vd-consensus").textContent = + sig === "—" ? "keine Daten" + : `Modul-Konsens: ${strength} ${dir} · ${agree}/${total} Module für ${sig}`; + $("vd-needle").style.left = ((bias + 1) / 2 * 100).toFixed(1) + "%"; + + // Modul-Chips + const vote = (x) => x > 0 ? ["long", "▲"] : x < 0 ? ["short", "▼"] : ["flat", "—"]; + $("vd-modules").innerHTML = (v.votes || []).map(mod => { + const [c, a] = vote(mod.vote); + return `
` + + `${mod.name}` + + `${a}
` + + `
${mod.detail || ""}
`; + }).join(""); +} + + +function renderStatsCompact(sc, m) { + sc = sc || {}; + const cs = curSym(m || {}); + const f2 = (v) => (v == null ? "—" : (+v).toFixed(2)); + const block = (label, s) => { + if (!s || !s.n) return `
${label}
` + + `
noch keine Trades
`; + const ratioCls = (s.ratio != null && s.ratio >= 1) ? "up" : "down"; + const pfCls = (s.pf != null && s.pf >= 1) ? "up" : "down"; + const pnlCls = (s.pnl >= 0) ? "up" : "down"; + return `
${label} · ${s.n} Trades
` + + `
` + + `WR ${s.wr}%` + + `PF ${f2(s.pf)}` + + `Verh ${f2(s.ratio)}` + + `Øgew +${cs}${f2(s.avg_win)}` + + `Øverl ${cs}${f2(s.avg_loss)}` + + `PnL ${s.pnl >= 0 ? "+" : ""}${cs}${f2(s.pnl)}` + + `
`; + }; + $("stx-row").innerHTML = block("Woche", sc.week) + block("Gesamt", sc.all); +} + +function renderGaps(g, m) { + const el = $("gaps-list"); + const list = (g && g.gaps) || []; + if (!list.length) { + el.innerHTML = 'keine offenen Gaps'; + return; + } + const bid = (m || {}).bid; + el.innerHTML = list.map(x => { + const up = x.dir === "up"; + const arr = up ? "▲" : "▼"; + const dist = bid != null ? (x.fill - bid) : null; + const ds = dist != null ? ` (${dist >= 0 ? "+" : ""}${fmt(dist)})` : ""; + return `
${arr} ${x.dir.toUpperCase()}` + + `${fmt(x.lo)}–${fmt(x.hi)}` + + `schließt bei ${fmt(x.fill)}${ds}` + + `${x.date}
`; + }).join(""); +} + +// Marktstruktur (M30) — reine Anzeige (HH/HL/LH/LL, BOS, Regressionskanal). +function renderStructure(s) { + s = s || {}; + const TR = { up: ["▲ Aufwärts", "up"], down: ["▼ Abwärts", "down"], range: ["→ Seitwärts", "dim"] }; + const [txt, cls] = TR[s.trend] || ["—", "dim"]; + const tEl = $("ms-trend"); + tEl.textContent = txt; tEl.className = "ms-trend " + cls; + + // Swing-Chips: HH/HL = bullisch (grün), LH/LL = bärisch (rot) + const chips = (s.swings || []).map(sw => { + const bull = sw.type === "HH" || sw.type === "HL"; + const c = (sw.type === "H" || sw.type === "L") ? "dim" : (bull ? "up" : "down"); + return `${sw.type}`; + }).join(''); + $("ms-swings").innerHTML = chips || ''; + + // BOS + const b = s.bos; + const bEl = $("ms-bos"); + if (b) { + const up = b.dir === "up"; + bEl.innerHTML = `BOS` + + `${up ? "▲ über" : "▼ unter"} ${fmt(b.level)}` + + `vor ${b.bars_ago} Bars`; + bEl.classList.remove("hidden"); + } else { bEl.classList.add("hidden"); } + + // Kanal: Richtung + Position (0 = unten, 1 = oben) + const ch = s.channel; + const CD = { up: ["Aufwärtskanal", "up"], down: ["Abwärtskanal", "down"], flat: ["Seitwärtskanal", "dim"] }; + if (ch) { + const [ctxt, ccls] = CD[ch.dir] || ["Kanal", "dim"]; + const dEl = $("ms-chan-dir"); + dEl.textContent = ctxt; dEl.className = ccls; + const pct = Math.round((ch.pos != null ? ch.pos : 0.5) * 100); + $("ms-chan-fill").style.width = pct + "%"; + $("ms-chan-dot").style.left = pct + "%"; + const zone = pct <= 25 ? "am unteren Rand" : pct >= 75 ? "am oberen Rand" : "in der Mitte"; + $("ms-chan-note").textContent = `Kurs ${zone} (${pct}%)`; + $("card-structure").classList.remove("hidden"); + } else { + $("ms-chan-dir").textContent = "—"; + $("ms-chan-note").textContent = ""; + $("ms-chan-fill").style.width = "0%"; + } +} + +function render(d) { + // ── Header ────────────────────────────── + $("conn").className = "dot " + (d.connected ? "on" : "off"); + $("symbol").textContent = d.symbol || "—"; + const m = d.market || {}; + + // ── Server- (Broker) + Lokalzeit ──────── + const _p2 = (n) => String(n).padStart(2, "0"); + // server_time ist Broker-Wallclock, als UTC etikettiert → getUTC* zeigt sie 1:1 + const srv = m.server_time ? new Date(m.server_time) : null; + const srvStr = (srv && !isNaN(srv)) + ? `${_p2(srv.getUTCHours())}:${_p2(srv.getUTCMinutes())}:${_p2(srv.getUTCSeconds())}` + : "—"; + $("hdr-clock").textContent = `Srv ${srvStr} · Lok ${new Date().toLocaleTimeString("de-DE")}`; + + // ── Börsen-Session ────────────────────── + const ses = d.session || {}; + let stxt = ses.phase || "—"; + if (ses.next_open) stxt += ` · ${ses.next_open.name}-Open in ${ses.next_open.in_min} min`; + // 🕘 Prime-Time 21–22 Uhr (Berlin ≈ Browser-Lokalzeit): gemessen die mit Abstand + // stärksten Stunden (backtest_hourly: netto +0,25/+0,49 — Rest ~breakeven). + // 📰 News-Sturm: viele frische Öl-Headlines (news.storm_state) = Ereignis läuft + // → Vorsicht/Vola. Beides REINE ANZEIGE, kein Filter. + const _h = new Date().getHours(); + if (_h === 21 || _h === 22) stxt = `🕘 PRIME-TIME · ${stxt}`; + const storm = (d.news_storm || {}).level; + if (storm === "storm") stxt = `📰 NEWS-STURM · ${stxt}`; + else if (storm === "elevated") stxt = `📰 viele News · ${stxt}`; + $("session").textContent = stxt; + $("session").className = "session-line " + + (storm === "storm" ? "warn" + : (_h === 21 || _h === 22) ? "warn" + : ses.just_opened ? "warn" : (ses.active && ses.active.length) ? "ok" : "dim"); + + // ── Konto: Balance / Equity ───────────── + const cs = curSym(m); + $("balance").textContent = m.balance != null ? cs + fmt(m.balance, 2) : "—"; + if (m.equity != null) { + $("equity").textContent = cs + fmt(m.equity, 2); + $("equity").className = (m.balance != null) + ? (m.equity >= m.balance ? "up" : "down") : ""; + } else { $("equity").textContent = "—"; $("equity").className = ""; } + + // ── Tages-P/L (realisiert heute + offene Position) ─────── + const dp = d.day_pl || {}; + if (dp.total != null) { + $("day-pl").textContent = (dp.total >= 0 ? "+" : "") + cs + fmt(dp.total, 2); + $("day-pl").className = dp.total > 0 ? "up" : dp.total < 0 ? "down" : ""; + } else { $("day-pl").textContent = "—"; $("day-pl").className = ""; } + + // ── Nähe zu S/R (Farb-Signal, innerhalb 0,3×ATR) ────────── + const srl = d.sr_levels || {}; + const atrRef = (d.trail || {}).atr || m.atr_m15 || 0; + const near = atrRef ? atrRef * 0.30 : 0.05; + let priceCls = ""; + if (m.bid != null) { + const dRes = srl.resistance != null ? srl.resistance - m.bid : Infinity; + const dSup = srl.support != null ? m.bid - srl.support : Infinity; + if (dRes >= 0 && dRes <= near && dRes <= dSup) priceCls = "near-res"; + else if (dSup >= 0 && dSup <= near && dSup < dRes) priceCls = "near-sup"; + } + + // ── Header: Kurs (färbt sich nahe S/R) + G/V der offenen Position (netto) ── + $("hdr-price").textContent = m.bid != null ? fmt(m.bid) : "—"; + $("hdr-price").className = priceCls; + const hpos = d.position || {}; + if (hpos.ticket) { + const wht = d.wht_pct || 0; + const raw = hpos.pnl || 0; + const netHdr = raw > 0 ? raw * (1 - wht / 100) : raw; + $("hdr-pnl").textContent = (netHdr >= 0 ? "+" : "") + cs + fmt(netHdr, 2); + $("hdr-pnl").className = netHdr > 0 ? "up" : netHdr < 0 ? "down" : ""; + } else { $("hdr-pnl").textContent = "—"; $("hdr-pnl").className = ""; } + + // ── S/R-Annäherungs-Warnung (Position-Kachel) — NUR wenn kein reicherer + // Positions-Hint (pos-srhint) läuft, sonst doppeltes „nähert sich Widerstand". + const hasRichHint = !!((d.position && d.position.ticket) && d.sr_close_hint); + if (hasRichHint) { + $("cur-near").className = "cur-near"; + $("cur-near").textContent = ""; + } else { + $("cur-near").className = "cur-near " + priceCls; + $("cur-near").textContent = + priceCls === "near-res" ? `▲ nähert sich Widerstand ${fmt(srl.resistance)}` : + priceCls === "near-sup" ? `▼ nähert sich Unterstützung ${fmt(srl.support)}` : ""; + } + + // ── Gesamtempfehlung (aggregiert) ─────── + renderVerdict(d.verdict || {}); + renderStructure(d.structure); + renderStatsCompact(d.stats_compact, d.market); + renderGaps(d.gaps, d.market); + + // ── Position (Kurs/G/V stehen jetzt im Header) ── + const p = d.position || {}; + + // ── Trade-Bereich über der Gesamtempfehlung (nur bei offener Position) ── + const tb = $("trade-bar"); + if (tb) { + if (p.ticket) { + tb.classList.remove("hidden"); + const long = p.order_type === 0; + const dEl = $("tb-dir"); + dEl.className = "tb-val tb-dir " + (long ? "up" : "down"); + dEl.textContent = long ? "▲ LONG" : "▼ SHORT"; + $("tb-lots").textContent = fmt(p.lots, 2); + $("tb-margin").textContent = p.margin ? cs + fmt(p.margin, 2) : "—"; + // SL/TP-Felder nur befüllen, wenn nicht gerade bearbeitet (kein Tipp-Konflikt) + const slEl = $("tb-sl"), tpEl = $("tb-tp"); + if (document.activeElement !== slEl) slEl.value = p.sl ? fmt(p.sl, 2) : ""; + if (document.activeElement !== tpEl) tpEl.value = p.tp ? fmt(p.tp, 2) : ""; + } else { + tb.classList.add("hidden"); + } + } + + // ── Notfall-Stop: Feld wieder eingeblendet (User-Vorgabe 2026-07-22; war + // 2026-07-17 entfernt, Schutz lief nur noch über Broker-SL+Trailing — Backend + // (set_emergency/emergency_state.json) blieb die ganze Zeit aktiv/persistent). + // %-Modus (auto_emergency_pct>0): der Auto-Arm nimmt pct% der Balance → im + // Flat-Zustand diesen dynamischen Wert zeigen statt des Fixwerts. + const emg = d.emergency_loss; + const emgPct = d.emergency_pct || 0; + const emgRem = (emgPct > 0 && d.balance) + ? Math.round(emgPct / 100 * d.balance * 100) / 100 + : d.emergency_remember; + const ei = $("emg-input"); + if (ei && document.activeElement !== ei) { + const shown = emg != null ? emg : (emgRem || ""); + ei.value = shown === "" ? "" : String(shown); + } + // („Gewinn mitnehmen"-Eingabe aus dem Dashboard entfernt — Backend-Funktion + // set_takeprofit/api bleibt erhalten, nur ohne UI.) + + // ── Trailing ──────────────────────────── + const tr = d.trail || {}; + $("trail-state").textContent = tr.enabled ? "AKTIV" : "aus"; + $("trail-state").className = tr.enabled ? "up" : "dim"; + $("trail-phase").textContent = tr.phase || "—"; + $("trail-sl").textContent = fmt(tr.last_sl, 3); + $("trail-tp").textContent = fmt(tr.last_tp, 3); + $("trail-atr").textContent = `${fmt(tr.atr, 3)} · ${tr.atr_tf || "—"}`; + + // ── Wellen-Meldungen (jetzt in „Meldungen"; Signal-Zeile entfällt — steht als + // Headline in der Gesamtempfehlung) ────────────────────── + const w = d.wave_signal || {}, ws = d.wave || {}; + $("wave-setup").textContent = w.setup || "—"; + // Überdehnungs-Anzeige (KEIN Handelssignal — gemessen ~Münzwurf, nur Kontext). + // Reaktionsrichtung explizit: LONG=überverkauft→nach oben, SHORT=überkauft→nach unten. + const bn = ws.bounce || {}; + const rword = bn.dir === "LONG" ? "nach oben ↑" : "nach unten ↓"; + const tf = bn.tf ? ` (${bn.tf})` : ""; + const be = $("wave-bounce"); + if (bn.state === "active") { + be.className = "bounce-ind warn strong"; + be.textContent = `⚠ stark überdehnt${tf} — Reaktion ${rword} möglich`; + } else if (bn.state === "expected") { + be.className = "bounce-ind warn"; + be.textContent = `⚠ überdehnt${tf} — Reaktion ${rword} möglich`; + } else { + be.className = "bounce-ind idle"; // immer sichtbar (Ruhe-Zustand) + be.textContent = "Überdehnung: keine"; + } + // Volatilitäts-Squeeze-Breakout (additiver Setup, gemessen 2-Stichproben-positiv): + // active = Ausbruch aus Kompression (echtes Signal → grün/rot) · armed = komprimiert, + // Ausbruch steht bevor (grau) · sonst keine Kompression. + const sq = ws.squeeze || {}; + const sqEl = $("wave-squeeze"); + if (sqEl) { + const arr = sq.dir === "LONG" ? "▲" : "▼"; + const bx = sq.box_atr != null ? ` · Box ${sq.box_atr}×ATR` : ""; + if (sq.state === "active") { + sqEl.className = "squeeze-ind fire " + (sq.dir === "LONG" ? "long" : "short"); + sqEl.textContent = `🚀 Squeeze-Breakout ${arr} ${sq.dir} @ ${fmt(sq.level, 3)}${bx}`; + } else if (sq.state === "armed") { + sqEl.className = "squeeze-ind armed"; + sqEl.textContent = `🎯 Kompression${bx} — Ausbruch ${arr} steht bevor`; + } else { + sqEl.className = "squeeze-ind idle"; + sqEl.textContent = "Squeeze: keine Kompression"; + } + } + if (ws.direction && ws.wave_start != null) { + $("wave-move").textContent = + `${ws.direction === "up" ? "▲" : "▼"} ab ${fmt(ws.wave_start)}` + + (ws.move_atr != null ? ` ${ws.move_atr >= 0 ? "+" : ""}${fmt(ws.move_atr, 2)}×ATR` : ""); + } else $("wave-move").textContent = "—"; + // TF-Ampel: M1/M5/M15/M30 grün(auf)/rot(ab); frische EMA12/50-Kreuzung + // (≤3 Bars) → blinkendes ⟳ + Richtungspfeil. + const turns = ws.tf_turns || {}; + $("wave-turns").innerHTML = ["M1", "M5", "M15", "M30", "H1"].map(tf => { + const t = turns[tf] || {}; + const cls = t.dir > 0 ? "up" : t.dir < 0 ? "down" : "flat"; + const arr = t.dir > 0 ? "▲" : t.dir < 0 ? "▼" : "·"; + const fresh = t.fresh ? " fresh" : ""; + const turn = t.fresh ? `⟳${arr}` : arr; + const ago = (t.bars_ago != null && t.fresh) ? `${t.bars_ago}` : ""; + return `${tf} ${turn}${ago}`; + }).join(""); + // (S/R-Zeile im Position-Bereich ausgeblendet — die Level stehen im Chart; + // die Preis-Färbung/„nähert sich"-Warnung nutzt srl weiterhin.) + $("wave-reasons").textContent = (w.reasons || []).slice(0, 3).join(" · "); + + // Elliott/FVG + TradersUnion: keine Einzel-Kacheln mehr (ausgeblendet) — + // beide fließen weiterhin in die Gesamtempfehlung (engine._verdict) ein. + + // ── KI-Agent ──────────────────────────── + const ag = (d.agent || {}).advisory; + if (ag) { + const b = ag.bias || "NEUTRAL"; + const bc = b === "LONG" ? "long" : b === "SHORT" ? "short" : ""; + const ba = b === "LONG" ? "▲" : b === "SHORT" ? "▼" : "◌"; + $("agent-bias").className = "signal " + bc; + $("agent-bias").textContent = + `${ba} ${b} · ${ag.confidence}%` + (ag.timeframe ? ` · ${ag.timeframe}` : ""); + $("agent-head").textContent = ag.headline || ""; + $("agent-reason").textContent = ag.reasoning || ""; + $("agent-risks").innerHTML = (ag.risks || []).map(r => `
  • ${r}
  • `).join(""); + } else { + $("agent-bias").className = "signal dim"; + $("agent-bias").textContent = (d.agent || {}).busy ? "◌ analysiere …" : "◌ —"; + $("agent-head").textContent = ""; + $("agent-reason").textContent = (d.agent || {}).error || ""; + $("agent-risks").innerHTML = ""; + } + + // ── Aktionsleiste: Zustände ───────────── + const hasPos = !!p.ticket; + const longBtn = document.querySelector('.btn[data-side="long"]'); + const shortBtn = document.querySelector('.btn[data-side="short"]'); + const closeBtn = document.querySelector('.btn[data-side="close"]'); + longBtn.disabled = hasPos; + shortBtn.disabled = hasPos; + closeBtn.disabled = !hasPos; + $("btn-squeeze").classList.toggle("on", !!d.auto_squeeze); + $("btn-trail").classList.toggle("on", !!(d.trail || {}).enabled); + + // Empfehlungs-Blinken: LONG/SHORT-Button blinkt, sobald die Welle eine + // Richtung empfiehlt (= wie in der Welle-Kachel sichtbar) und keine Position + // offen ist. Nicht mehr an die 70%-Auto-Schwelle gekoppelt. + longBtn.classList.toggle("blink", !hasPos && w.signal === "LONG"); + shortBtn.classList.toggle("blink", !hasPos && w.signal === "SHORT"); + + // Ton bei Empfehlung (nur flat): sofort beim Signal-Wechsel, danach Erinnerung + // alle 30 s, solange der Button blinkt. Kein Ton bei offener Position. + const recSig = (!hasPos && (w.signal === "LONG" || w.signal === "SHORT")) + ? w.signal : null; + if (recSig) beepRecommendation(recSig, recSig !== _lastRecSignal); + _lastRecSignal = recSig; + + // CLOSE-Alarm (Blink + Ton) NUR wenn der Gewinn ÜBER dem eingestellten S/R- + // Mindestgewinn liegt (User-Vorgabe). Feld leer/0 → wie bisher: jeder Gewinn > 0. + // Deckt sich mit dem Auto-Close-Gate im Backend (unter der Schwelle schließt der + // Bot ohnehin nicht → dann auch kein Alarm/Ton). + const posLong = p.order_type === 0; + const minGain = d.sr_close_min_gain || 0; + const gainOK = hasPos && (p.pnl || 0) > minGain; + const flipClose = gainOK && + ((posLong && w.signal === "SHORT") || (!posLong && w.signal === "LONG")); + // S/R-Close-Empfehlung (gemessen, nur das robuste Segment): Kurs AM Level + + // schwacher Anlauf → Level hält ~74 % → Close schlägt Laufenlassen. Text-Hinweis + // in der Position-Kachel immer; Blink/Ton nur über dem Mindestgewinn. + const srHint = d.sr_close_hint || null; // vorhanden = Kurs am Level + const srAuto = !!d.auto_sr_close; + const srClose = !!(srHint && srHint.close); // P(break) < Schwelle → schließen + const closeAlert = flipClose || (gainOK && srClose); + closeBtn.classList.toggle("blink", closeAlert); + if (closeAlert) beepCloseAlert(); // Blinken + Ton (gedrosselt, alle 4 s) + const hintEl = $("pos-srhint"); + if (hintEl) { + if (hasPos && srHint) { + const lvlName = posLong ? "Widerstand" : "Support"; + // Bestätigungs-Preis (level±0,5×ATR, dieselbe Definition wie beim Modell- + // Training): ein kurzer Docht über die Levelzahl ist noch KEIN Durchbruch. + const confirmTxt = srHint.confirm != null + ? ` (Bestätigung erst ab ${fmt(srHint.confirm, 3)})` : ""; + if (!srHint.at_level) { + // Vorwarnung: Kurs nähert sich dem Level (0,15–0,6×ATR davor). + // Schwelle dynamisch aus der Config (sr_close_pbreak), nicht hart 60. + const thr = (d.sr_close_pbreak || 0.60) * 100; + hintEl.className = "pos-srhint approach"; + hintEl.textContent = `🎯 Nähert sich ${lvlName} ${fmt(srHint.level, 3)} ` + + `(${srHint.gap_atr}×ATR) · Durchbruch ${srHint.p_break}%${confirmTxt}` + + (srHint.p_break < thr ? " → Bot schließt dort (im Plus)" : " → dürfte durchlaufen"); + } else if (srClose) { + hintEl.className = "pos-srhint warn"; + const tail = (srAuto && gainOK) ? "🤖 Bot schließt automatisch" : "Close erwägen"; + hintEl.textContent = `🎯 Am ${lvlName} ${fmt(srHint.level, 3)} · Durchbruch nur ` + + `${srHint.p_break}%${confirmTxt} → prallt wahrscheinlich ab → ${tail}`; + } else { + hintEl.className = "pos-srhint run"; + hintEl.textContent = `🎯 Am ${lvlName} ${fmt(srHint.level, 3)} · Durchbruch ` + + `${srHint.p_break}%${confirmTxt} wahrscheinlich → läuft weiter`; + } + } else { + hintEl.className = "pos-srhint hidden"; + hintEl.textContent = ""; + } + } + // Schalter-Zustand: Auto-Close am S/R an/aus + Mindestgewinn + const srBtn = $("srclose-btn"); + if (srBtn) { + srBtn.textContent = srAuto ? "AN" : "AUS"; + srBtn.title = srAuto ? "Bot schließt am Level (im Plus)" : "nur Hinweis, kein Auto-Close"; + srBtn.classList.toggle("on", srAuto); + } + const srMin = d.sr_close_min_gain || 0; + const smi = $("srmin-input"); + if (smi && document.activeElement !== smi) { + smi.value = srMin > 0 ? String(srMin) : ""; + } + // Einblendung: der Bot hat am S/R automatisch geschlossen (Zähler stieg) + const src = d.sr_close_count; + if (src != null) { + if (_lastSrCloseCount != null && src > _lastSrCloseCount) { + toast("🎯 Bot hat am Level geschlossen (schwacher Anlauf, Gewinn gesichert)", true); + } + _lastSrCloseCount = src; + } + + // ── Letzte Trades ─────────────────────── + const tds = (d.last_trades || []).slice(0, 5); + $("trades").innerHTML = tds.map(x => { + const pnl = x.pnl || 0; + const cls = pnl > 0 ? "up" : pnl < 0 ? "down" : "dim"; + const arr = (x.direction || "").toUpperCase() === "BUY" ? "▲" : "▼"; + const ts = (x.exit_time || x.entry_time || 0) * 1000; + const hm = ts ? new Date(ts).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }) : "—"; + const cb = (x.closed_by || "").toUpperCase().slice(0, 2) || "—"; + return `
    ${arr}${pnl >= 0 ? "+" : ""}${fmt(pnl, 2)}
    ` + + `
    ${hm} ${cb}
    `; + }).join("") || '
    keine
    '; +} + +// ── WebSocket mit Auto-Reconnect ─────────────────────────────────────────── +let sock = null, retry = null; +// `lastTs` dedupliziert zwischen WS-Push und REST-Poll (beide liefern denselben +// `engine.snapshot_cached().ts`) — nur die jeweils NEUERE Quelle rendert, kein +// Flackern/Doppel-Render, wenn beide fast gleichzeitig eintreffen. +let lastTs = 0; +function _applySnapshot(d) { + if (typeof d.ts === "number" && d.ts <= lastTs) return; // schon Neueres gezeigt + if (typeof d.ts === "number") lastTs = d.ts; + render(d); +} +function connect() { + const proto = location.protocol === "https:" ? "wss" : "ws"; + sock = new WebSocket(`${proto}://${location.host}/ws`); + sock.onmessage = (ev) => { + try { _applySnapshot(JSON.parse(ev.data)); } catch (e) { console.warn(e); } + }; + sock.onclose = () => { + $("conn").className = "dot off"; + clearTimeout(retry); + retry = setTimeout(connect, 2000); + }; + sock.onerror = () => sock.close(); +} + +// ── REST-Polling als Sicherheitsnetz (2026-07-22, User-Wunsch) ──────────────── +// Zusätzlich zum WS-Push: alle POLL_MS Sekunden `/api/snapshot` abfragen. Fängt +// Fälle auf, in denen der WebSocket STILL hängt (kein onclose, keine Zustellung +// mehr — z. B. schwaches WireGuard-Signal, App-Wechsel ohne Visibility-Event), +// ohne auf einen Reconnect warten zu müssen. Der `lastTs`-Dedup verhindert +// Doppel-Renders, solange der WS normal liefert — reine Redundanz, kein Konflikt. +const POLL_MS = 4000; +async function pollSnapshot() { + if (document.hidden) return; // seite unsichtbar → spart Akku/Daten, Wake-Handler holt beim Zurückkommen sofort nach + try { + const d = await (await fetch("api/snapshot")).json(); + _applySnapshot(d); + } catch (e) { /* still — WS-Reconnect kümmert sich, das ist nur der Fallback */ } +} +setInterval(pollSnapshot, POLL_MS); + +// ── Trade-Steuerung ──────────────────────────────────────────────────────── +const TOKEN_KEY = "oil_token"; + +// Token robust speichern: localStorage UND als langlebiges Cookie. So bleibt +// "einmal eingeben" auch erhalten, wenn localStorage zickt (PWA/Privat-Tab). +function saveToken(tok) { + try { localStorage.setItem(TOKEN_KEY, tok); } catch (e) {} + try { + document.cookie = TOKEN_KEY + "=" + encodeURIComponent(tok) + + ";path=/;max-age=31536000;SameSite=Lax"; + } catch (e) {} +} +function getToken() { + let t = null; + try { t = localStorage.getItem(TOKEN_KEY); } catch (e) {} + if (t) return t; + const m = document.cookie.match(new RegExp("(?:^|; )" + TOKEN_KEY + "=([^;]*)")); + return m ? decodeURIComponent(m[1]) : null; +} +function clearToken() { + try { localStorage.removeItem(TOKEN_KEY); } catch (e) {} + try { document.cookie = TOKEN_KEY + "=;path=/;max-age=0"; } catch (e) {} +} + +let lastSnap = {}; +const origRender = render; +render = (d) => { lastSnap = d; origRender(d); }; // letzten Snapshot merken + +// Generisches Modal → Promise(true/false). mode "confirm" | "token" +function modal(title, msg, mode = "confirm", danger = false) { + return new Promise((resolve) => { + const box = $("modal"), inp = $("modal-input"); + $("modal-title").textContent = title; + $("modal-msg").textContent = msg || ""; + $("modal-msg").className = "box-msg" + (danger ? " danger" : ""); + $("modal-ok").textContent = danger ? "Trotzdem" : "Bestätigen"; + inp.classList.toggle("hidden", mode !== "token"); + if (mode === "token") inp.value = ""; + box.classList.remove("hidden"); + if (mode === "token") setTimeout(() => inp.focus(), 50); + const done = (val) => { + box.classList.add("hidden"); + $("modal-ok").onclick = $("modal-cancel").onclick = null; + resolve(val); + }; + $("modal-ok").onclick = () => done(mode === "token" ? (inp.value.trim() || null) : true); + $("modal-cancel").onclick = () => done(mode === "token" ? null : false); + }); +} + +function toast(msg, ok = true) { + const el = $("toast"); + el.textContent = msg; + el.className = "toast " + (ok ? "ok" : "err"); + clearTimeout(toast._t); + toast._t = setTimeout(() => el.classList.add("hidden"), 3500); +} + +// Token sicherstellen (gespeichert; bei Bedarf abfragen + serverseitig prüfen) +async function ensureToken() { + // Token-Pflicht serverseitig abgeschaltet? Dann kein Fenster, kein Header nötig. + if (lastSnap.auth_required === false) return ""; + let tok = getToken(); + while (true) { + if (!tok) { + tok = await modal("App-Token", "Token aus der Server-Konsole eingeben " + + "(steht in [web] api_token).", "token"); + if (!tok) return null; + } + try { + const r = await fetch("api/auth/check", { method: "POST", + headers: { "X-Auth-Token": tok } }); + if (r.ok) { saveToken(tok); return tok; } + } catch (e) { /* Netzfehler → unten behandelt */ } + clearToken(); tok = null; + const retry = await modal("Token ungültig", "Erneut eingeben?", "confirm"); + if (!retry) return null; + } +} + +async function postAuth(url, tok) { + const r = await fetch(url, { method: "POST", + headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, + body: JSON.stringify({}) }); + if (r.status === 401) { clearToken(); throw new Error("401"); } + return r.json(); +} + +async function sendOrder(side, tok) { + const r = await fetch("api/order", { method: "POST", + headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, + body: JSON.stringify({ side }) }); + if (r.status === 401) { clearToken(); throw new Error("401"); } + return r.json(); +} + +// Empfehlungs-Abgleich: zeigt Welle UND KI-Copilot, warnt bei Konflikt +function recInfo(side) { + const w = lastSnap.wave_signal || {}; + const sig = w.signal || "—"; + const conf = w.conf_pct || 0; + const want = side === "long" ? "LONG" : "SHORT"; + const minc = lastSnap.min_conf || 70; + + // ── Welle (primäres Signal → bestimmt die Warnung) ── + let waveLine, danger = false; + if (sig === "WARTEN" || sig === "—") { + waveLine = "⚠ Welle: WARTEN — kein klarer Trend"; danger = true; + } else if (sig !== want) { + waveLine = `⚠ Welle GEGEN dich: ${sig} ${conf}%`; danger = true; + } else if (conf < minc) { + waveLine = `Welle ${sig} ${conf}% (unter Schwelle ${minc}%)`; + } else { + waveLine = `✓ Welle ${sig} ${conf}%`; + } + + // ── KI-Copilot (Kontext) ── + const adv = (lastSnap.agent || {}).advisory; + let agentLine = "· KI-Copilot: —"; + if (adv) { + const ab = adv.bias || "NEUTRAL", ac = adv.confidence || 0; + const mark = ab === want ? "✓" : ab === "NEUTRAL" ? "·" : "⚠"; + agentLine = `${mark} KI-Copilot: ${ab} ${ac}%`; + // Agent ist nur Kontext — die Warnung (danger) bestimmt die validierte Welle. + } + + return { line: waveLine + "\n" + agentLine, danger }; +} + +// Order-Buttons (LONG/SHORT/CLOSE). +// Eröffnen wird NIE blockiert/abgefragt — bei WARTEN/Gegen-Signal nur ein Hinweis +// (Toast), der Trade läuft trotzdem. Schließen direkt — außer im Minus: +// Sicherheitsabfrage (kein versehentlicher Verlust-Close). +document.querySelectorAll(".btn[data-side]").forEach(btn => { + btn.onclick = async () => { + if (btn.disabled) return; + const side = btn.dataset.side; + if (side === "long" || side === "short") { + const info = recInfo(side); // WARTEN/Gegen-Signal → Hinweis, der + if (info.danger) { // bestätigt werden muss (kein Block) + const sig = (lastSnap.wave_signal || {}).signal || "WARTEN"; + const head = (sig === "WARTEN" || sig === "—") + ? "ℹ Keine Empfehlung (WARTEN)" + : `ℹ Empfehlung steht GEGEN dich (${sig})`; + const ok = await modal(head, + info.line + `\n\n${side.toUpperCase()} trotzdem eröffnen?`, + "confirm", true); + if (!ok) return; // Abbrechen = doch nicht; sonst läuft er + } + } + if (side === "close") { // Sicherheitsabfrage bei Verlust + const p = lastSnap.position || {}; + const pnl = p.pnl || 0; + if (p.ticket && pnl < 0) { + const cs = curSym(lastSnap.market || {}); + const ok = await modal( + "⚠ Position im Minus schließen?", + `Aktueller Verlust: ${cs}${fmt(pnl, 2)}\n\nWirklich jetzt mit Verlust schließen?`, + "confirm", true); + if (!ok) return; + } + } + const tok = await ensureToken(); + if (tok === null) return; // null = Token abgebrochen ("" = kein Token nötig) + try { + const res = await sendOrder(side, tok); + res.ok ? toast(`${side.toUpperCase()} ausgeführt`, true) + : toast(`Fehler: ${res.error || "unbekannt"}`, false); + } catch (e) { + toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); + } + }; +}); + +// (Notfall-Stop-UI entfernt 2026-07-17 — POST /api/emergency existiert weiter, ohne UI.) +// („Gewinn mitnehmen"-UI entfernt — POST /api/takeprofit existiert weiter, ohne UI.) + +// Manuelles SL/TP (Trade-Bereich) — sendet beide Felder; deaktiviert das Trailing. +async function sendSltp() { + const slv = $("tb-sl").value, tpv = $("tb-tp").value; + const tok = await ensureToken(); + if (tok === null) return; + $("tb-sl").blur(); $("tb-tp").blur(); + try { + const r = await fetch("api/sltp", { method: "POST", + headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, + body: JSON.stringify({ sl: slv === "" ? null : parseFloat(slv), + tp: tpv === "" ? null : parseFloat(tpv) }) }); + if (r.status === 401) { clearToken(); throw new Error("401"); } + const j = await r.json(); + if (j.ok) toast(`SL/TP gesetzt${j.trail_was_on ? " · Trailing AUS" : ""}`, true); + else toast(j.error || "Broker lehnte ab", false); + } catch (e) { + toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); + } +} +["tb-sl", "tb-tp"].forEach(id => { + $(id).addEventListener("change", sendSltp); + $(id).addEventListener("keydown", e => { + if (e.key === "Enter") { e.preventDefault(); sendSltp(); } + }); +}); + +// Mute-Button (oben rechts): alle akustischen Signale an/aus +function _updateMuteBtn() { + const b = $("mute-btn"); + if (b) { b.textContent = _muted ? "🔇" : "🔊"; b.classList.toggle("muted", _muted); } +} +$("mute-btn").onclick = () => { + _muted = !_muted; + localStorage.setItem("oil_muted", _muted ? "1" : "0"); + _updateMuteBtn(); + toast(_muted ? "🔇 Alle Töne aus" : "🔊 Töne an", true); +}; +_updateMuteBtn(); + +// Toggle-Buttons (AUTO/TRAIL/S-R-Close) — ohne Bestätigung +$("btn-squeeze").onclick = () => toggle("api/autosqueeze", "Auto-Squeeze (autonom!)"); +$("btn-trail").onclick = () => toggle("api/trail", "Trailing"); +$("srclose-btn").onclick = () => toggle("api/srclose", "Auto-S/R-Close"); + +// Mindestgewinn für den S/R-Auto-Close (0/leer = aus) +async function setSrMin() { + const raw = $("srmin-input").value; + const v = raw === "" ? 0 : parseFloat(raw); + if (isNaN(v) || v < 0) { toast("Bitte Betrag ≥ 0 eingeben (0 = aus)", false); return; } + const tok = await ensureToken(); + if (tok === null) return; + $("srmin-input").blur(); + try { + const r = await fetch("api/srclose_min", { method: "POST", + headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, + body: JSON.stringify({ value: v }) }); + if (r.status === 401) { clearToken(); throw new Error("401"); } + toast(v > 0 ? `Mindestgewinn +${v} gesetzt` : "Mindestgewinn aus", true); + } catch (e) { + toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); + } +} +// Feld sitzt in der Button-Zeile: setzt beim Verlassen/Enter automatisch +$("srmin-input").addEventListener("change", setSrMin); +// Enter NUR blurren → das change-Event feuert setSrMin genau EINMAL (und nur, wenn +// der Wert wirklich geändert wurde). Vorher: keydown + change = doppelter Send, +// und Enter auf leerem Feld schickte ungewollt 0 (Fix 2026-07-22). +$("srmin-input").addEventListener("keydown", e => { + if (e.key === "Enter") { e.preventDefault(); e.target.blur(); } +}); + +// Notfall-Stop: manueller Verlust-Wert, bei dem der Server sofort schließt. +// Wieder eingeblendet 2026-07-22 (Backend/Persistenz waren die ganze Zeit aktiv). +async function setEmg() { + const raw = $("emg-input").value; + const v = raw === "" ? 0 : parseFloat(raw); + if (isNaN(v) || v < 0) { toast("Bitte Betrag ≥ 0 eingeben (0 = aus)", false); return; } + const tok = await ensureToken(); + if (tok === null) return; + $("emg-input").blur(); + try { + const r = await fetch("api/emergency", { method: "POST", + headers: { "X-Auth-Token": tok, "Content-Type": "application/json" }, + body: JSON.stringify({ value: v > 0 ? v : null }) }); + if (r.status === 401) { clearToken(); throw new Error("401"); } + toast(v > 0 ? `Notfall-Stop bei −${v} gesetzt` : "Notfall-Stop aus", true); + } catch (e) { + toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); + } +} +$("emg-input").addEventListener("change", setEmg); +// Enter blurrt nur (kein doppeltes Senden, kein ungewolltes 0 auf leerem Feld — +// derselbe Fix wie beim Mindestgewinn-Feld, 2026-07-22). +$("emg-input").addEventListener("keydown", e => { + if (e.key === "Enter") { e.preventDefault(); e.target.blur(); } +}); + +async function toggle(url, label) { + const tok = await ensureToken(); + if (tok === null) return; // null = abgebrochen ("" = kein Token nötig) + try { + const res = await postAuth(url, tok); + toast(`${label} ${res.enabled ? "AN" : "AUS"}`, true); + } catch (e) { + toast(e.message === "401" ? "Token abgelaufen — neu eingeben" : "Netzwerkfehler", false); + } +} + +// ── Modul-Menü / Views ───────────────────────────────────────────────────── +function showView(name) { + document.querySelectorAll(".view").forEach(v => v.classList.add("hidden")); + const el = document.getElementById("view-" + name); + if (el) el.classList.remove("hidden"); + document.querySelectorAll(".tab").forEach(t => + t.classList.toggle("active", t.dataset.view === name)); + if (name === "logs") loadLogs(); + if (name === "stats") loadStats(); + if (name === "news") loadNews(); + if (name === "charts") loadCharts(); +} +// Nur echte View-Tabs registrieren — NICHT den Mute-Button (der trägt .tab nur +// fürs Styling; ohne [data-view]-Filter überschrieb das hier seinen onclick und +// showView(undefined) blendete alle Views aus). +document.querySelectorAll(".tab[data-view]").forEach(t => t.onclick = () => showView(t.dataset.view)); + +async function loadLogs() { + const el = $("logs-body"); + el.textContent = "lade …"; + try { + const d = await (await fetch("api/logs?lines=300")).json(); + if (d.error) { el.textContent = "Fehler: " + d.error; return; } + el.textContent = (d.lines || []).join("\n"); + el.scrollTop = el.scrollHeight; + } catch (e) { el.textContent = "Netzwerkfehler"; } +} + +function statBlock(title, s) { + const cs = curSym(lastSnap.market || {}); + if (!s || s.error) + return `

    ${title}

    ${(s && s.error) || "—"}
    `; + const money = (v, sign) => `${sign && v >= 0 ? '+' : ''}${cs}${(v || 0).toFixed(2)}`; + const pf = s.profit_factor != null ? s.profit_factor.toFixed(2) : "—"; + const hasWht = (s.wht_pct || 0) > 0; + const npf = s.net_profit_factor != null ? s.net_profit_factor.toFixed(2) : "—"; + + // Brutto-Block + let rows = ` +
    Trades${s.n_trades}
    +
    Winrate${(s.winrate || 0).toFixed(0)}%
    +
    P&L brutto${money(s.total_pnl, true)}
    +
    Profit-Faktor${pf}
    `; + + // Netto-Block (nur wenn WHT konfiguriert) + if (hasWht) { + rows += ` +
    WHT ${s.wht_pct}%−${cs}${(s.wht || 0).toFixed(2)}
    +
    P&L netto${money(s.net_pnl, true)}
    +
    Profit-Faktor netto${npf}
    `; + } + rows += ` +
    Ø Gewinn${hasWht ? ' netto' : ''}+${cs}${((hasWht ? s.net_avg_win : s.avg_win) || 0).toFixed(2)}
    +
    Ø Verlust${cs}${(s.avg_loss || 0).toFixed(2)}
    `; + + return `

    ${title}

    ${rows}
    `; +} +function setupRow(s) { + const cs = curSym(lastSnap.market || {}); + const hasWht = (lastSnap._wht || 0) > 0 || s.net_pnl != null; + const pnl = hasWht ? (s.net_pnl ?? s.total_pnl) : s.total_pnl; + const pf = hasWht ? s.net_profit_factor : s.profit_factor; + const cls = pnl >= 0 ? "up" : "down"; + return `
    +
    ${(s.setup || "—").replace(/ +
    ${s.n}× ${(s.winrate || 0).toFixed(0)}%
    +
    ${pnl >= 0 ? "+" : ""}${cs}${(pnl || 0).toFixed(2)}
    +
    PF ${pf != null ? pf.toFixed(2) : "—"}
    +
    `; +} + +// Auto-Squeeze-Monitor (B4): Entry-Edge (Mechanik) + Live-Trades vs. Erwartung. +const _SQM_VERDICT = { + ontrack: ["✓ trägt live", "up"], weak: ["○ dünn/schwach", "dim"], + drift: ["⚠ driftet — B5 prüfen", "down"], watch: ["… zu wenig Daten", "dim"], +}; +function renderSqueezeMonitor(d) { + const box = $("sqm-box"); + if (!d || d.error) { box.innerHTML = '
    Monitor n/v
    '; return; } + const m = d.mech || {}, lv = d.live || {}, ex = d.expect || {}; + const [vtxt, vcls] = _SQM_VERDICT[d.verdict] || ["—", "dim"]; + const row = (k, v, cls) => `
    ${k}${v}
    `; + const sign = (x, dg = 2) => (x == null ? "—" : (x >= 0 ? "+" : "") + Number(x).toFixed(dg)); + // Mechanik-Block (Entry-Edge isoliert, apples-to-apples zum Backtest) + let mechHtml; + if (m.error) mechHtml = `
    ${m.error}
    `; + else if (!m.n) mechHtml = '
    keine Ausbrüche im Fenster
    '; + else { + const orCls = m.oR >= (ex.or_lo || 0.14) ? "up" : m.oR > 0 ? "" : "down"; + mechHtml = row("Ausbrüche (30 T)", m.n) + row("Treffer", m.wr + " %") + + row("ØR", sign(m.oR, 3), orCls) + row("PF", (m.pf != null ? m.pf.toFixed(2) : "—"), m.pf > 1 ? "up" : "down"); + } + // Live-Block (echt, Gesamtsystem inkl. reale Exits) + let liveHtml; + if (!lv.n) liveHtml = '
    noch keine Auto-Squeeze-Trades
    '; + else { + const dn = lv.night || {}, dy = lv.day || {}; + liveHtml = row("Trades", lv.n) + row("Treffer", lv.wr + " %") + + row("Ø-Gewinn / -Verlust", `${sign(lv.avg_win)} / ${sign(lv.avg_loss)} €`) + + row("Verhältnis", lv.ratio != null ? lv.ratio.toFixed(2) : "—", lv.ratio >= 1 ? "up" : "down") + + row("Σ real", sign(lv.sum_eur) + " €", lv.sum_eur >= 0 ? "up" : "down") + + row("Tag / Nacht", `${sign(dy.sum)} / ${sign(dn.sum)} € (${dy.n || 0}/${dn.n || 0})`); + } + box.innerHTML = + `

    Auto-Squeeze-Monitor (B4)

    ` + + `${vtxt}
    ` + + `
    Entry-Edge · Mechanik (~30 T M5, isoliert)
    ` + + `
    ${mechHtml}
    Erwartung ØR +0,14…+0,23 · PF>1
    ` + + `
    Live-Trades · echt (Gesamtsystem)
    ` + + `
    ${liveHtml}
    ` + + `
    Mechanik = nur der Squeeze-Einstieg mit Backtest-Exit ` + + `(zeigt, ob der Edge lebt). Live = die echten Trades inkl. realer Exits/Eingriffe. ` + + `Klaffen sie auseinander, liegt es am Exit/Verhalten, nicht am Signal.
    `; +} +async function loadSqueezeMonitor() { + try { + const d = await (await fetch("api/squeeze_monitor")).json(); + renderSqueezeMonitor(d); + } catch (e) { $("sqm-box").innerHTML = '
    Monitor-Fehler
    '; } +} + +async function loadStats() { + loadSqueezeMonitor(); + const el = $("stats-body"), setEl = $("stats-setups"); + el.innerHTML = '
    lade …
    '; + try { + const d = await (await fetch("api/stats")).json(); + lastSnap._wht = d.wht_pct || 0; + el.innerHTML = statBlock("Heute", d.today) + statBlock("Woche", d.week) + + statBlock("Gesamt", d.all) + + `
    Trades in DB gesamt: ${d.n_total ?? "—"}
    `; + const ss = (d.setups || []).filter(x => !x.error); + setEl.innerHTML = ss.length + ? `
    ${d.wht_pct > 0 ? "P&L/PF = netto nach " + d.wht_pct + "% WHT" : "brutto"}
    ` + + ss.map(setupRow).join("") + : '
    keine Setup-Daten
    '; + } catch (e) { el.innerHTML = '
    Netzwerkfehler
    '; } +} + +async function loadNews() { + const sEl = $("news-sentiment"), bEl = $("news-body"); + bEl.innerHTML = '
    lade …
    '; sEl.textContent = ""; + try { + const d = await (await fetch("api/news")).json(); + const s = d.sentiment || {}; + if (s.score != null) { + const sc = s.score > 0.05 ? "up" : s.score < -0.05 ? "down" : "dim"; + sEl.innerHTML = `Sentiment ${s.score >= 0 ? '+' : ''}${(s.score || 0).toFixed(2)}` + + ` · ▲${s.n_bull || 0} ▼${s.n_bear || 0}`; + } + const hs = d.headlines || []; + bEl.innerHTML = hs.length ? hs.map(h => + `` + + `
    ${(h.title || '').replace(/` + + `
    ${(h.source || '').replace(/`).join("") + : `
    ${d.error || "noch keine News geladen"}
    `; + } catch (e) { bEl.innerHTML = '
    Netzwerkfehler
    '; } +} + +// ── Charts (Lightweight Charts): M1/M5/M15/M30/H1 untereinander mit Trend ── +const CHART_TFS = ["M1", "M5", "M15", "M30", "H1"]; +const _charts = {}; // tf → {chart, candle, s12, s50} +const _TREND_TXT = { up: ["▲ Aufwärts", "up"], down: ["▼ Abwärts", "down"], flat: ["→ Seitwärts", "flat"] }; + +function _ensureChartCard(tf) { + if (document.getElementById("chart-" + tf)) return; + const card = document.createElement("div"); + card.className = "chart-card"; card.id = "chart-" + tf; + card.innerHTML = + `
    ${tf}` + + `` + + `
    ` + + `
    `; + $("charts-body").appendChild(card); +} + +function _makeChart(tf) { + const el = document.getElementById("ccv-" + tf); + // Explizite Größe statt autoSize: autoSize hängt an ResizeObserver und lieferte + // auf Mobil-Browsern teils 0×0-Charts (leerer Tab). clientWidth ist gesetzt, + // weil loadCharts erst nach dem Einblenden des Tabs läuft. + const chart = LightweightCharts.createChart(el, { + width: el.clientWidth || 320, height: 200, + layout: { background: { color: "#0d1117" }, textColor: "#8b949e", fontSize: 11 }, + grid: { vertLines: { color: "#161b22" }, horzLines: { color: "#161b22" } }, + timeScale: { borderColor: "#30363d", timeVisible: true, secondsVisible: false }, + rightPriceScale: { borderColor: "#30363d" }, + crosshair: { mode: LightweightCharts.CrosshairMode.Normal }, + handleScale: { axisPressedMouseMove: false }, + }); + const candle = chart.addCandlestickSeries({ + upColor: "#3fb950", downColor: "#f85149", borderVisible: false, + wickUpColor: "#3fb950", wickDownColor: "#f85149", + }); + const line = (color) => chart.addLineSeries( + { color, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false }); + const s12 = line("#58a6ff"), s50 = line("#d29922"); // EMA12 blau, EMA50 amber + // Regressionskanal (nur M30 bekommt Daten): oben/unten gestrichelt, Mitte gepunktet + const chLine = (color, style) => chart.addLineSeries( + { color, lineWidth: 1, lineStyle: style, priceLineVisible: false, + lastValueVisible: false, crosshairMarkerVisible: false }); + const chU = chLine("#8b949e", 2), chM = chLine("#8b949e66", 1), chL = chLine("#8b949e", 2); + _charts[tf] = { chart, candle, s12, s50, chU, chM, chL }; + return _charts[tf]; +} + +function renderChart(tf, d) { + const off = d.broker_utc_offset || 0; + const B = d.bars || []; + if (d.error || !B.length) { + const tr = document.getElementById("ctr-" + tf); + if (tr) { tr.textContent = d.error ? "⚠ " + d.error : "keine Daten"; tr.className = "chart-trend flat"; } + return; + } + const candles = B.map(b => ({ time: b.t - off, open: b.o, high: b.h, low: b.l, close: b.c })); + const em = (arr) => (arr || []).map((v, i) => (B[i] ? { time: B[i].t - off, value: v } : null)).filter(Boolean); + const c = _charts[tf] || _makeChart(tf); + c.candle.setData(candles); + c.s12.setData(em(d.ema12)); + c.s50.setData(em(d.ema50)); + // Regressionskanal (M30) — 3 Linien deckungsgleich mit den Bars; sonst leeren + if (c.chU) { + const ch = d.channel; + if (ch && ch.upper) { + c.chU.setData(em(ch.upper)); c.chM.setData(em(ch.mid)); c.chL.setData(em(ch.lower)); + } else { + c.chU.setData([]); c.chM.setData([]); c.chL.setData([]); + } + } + // S/R-Linien + Zonen (Ranges) als horizontale Preislinien einzeichnen + (c.lines || []).forEach(pl => { try { c.candle.removePriceLine(pl); } catch (e) {} }); + c.lines = []; + const addLine = (p, color, style, title) => { + if (p == null) return; + c.lines.push(c.candle.createPriceLine({ price: p, color, lineWidth: 1, + lineStyle: style, axisLabelVisible: true, title: title || "" })); + }; + const LV = d.levels || {}; + // Verständliche Labels: nächstes Level ausgeschrieben, weitere nummeriert + (LV.res || []).forEach((p, i) => + addLine(p, "#1f6feb", 2, i === 0 ? "Widerstand" : `Widerstand ${i + 1}`)); + (LV.sup || []).forEach((p, i) => + addLine(p, "#58a6ff", 2, i === 0 ? "Unterstützung" : `Unterstützung ${i + 1}`)); + (LV.zones || []).forEach(z => { + addLine(z.hi, "#1f6feb", 1, "Zone"); addLine(z.lo, "#1f6feb", 1, ""); + }); + c.chart.timeScale().fitContent(); + const tr = document.getElementById("ctr-" + tf); + const [txt, cls] = _TREND_TXT[d.trend] || ["—", "flat"]; + tr.textContent = txt; tr.className = "chart-trend " + cls; + document.getElementById("clast-" + tf).textContent = candles[candles.length - 1].close.toFixed(3); +} + +async function loadCharts() { + if (typeof LightweightCharts === "undefined") { + $("charts-body").innerHTML = '
    Chart-Framework nicht geladen (Seite neu laden).
    '; + return; + } + for (const tf of CHART_TFS) { + _ensureChartCard(tf); + try { + const d = await (await fetch(`api/bars?tf=${tf}&n=100`)).json(); + renderChart(tf, d); + // Breite nachziehen (Rotation/Resize seit Erstellung) + const c = _charts[tf], el = document.getElementById("ccv-" + tf); + if (c && el && el.clientWidth > 0) c.chart.applyOptions({ width: el.clientWidth }); + } catch (e) { + // Fehler SICHTBAR machen statt stumm schlucken (war: leere Kacheln ohne Grund) + const tr = document.getElementById("ctr-" + tf); + if (tr) { tr.textContent = "⚠ " + (e && e.message ? e.message : "Fehler"); tr.className = "chart-trend flat"; } + } + } +} +window.addEventListener("resize", () => { + for (const tf of CHART_TFS) { + const c = _charts[tf], el = document.getElementById("ccv-" + tf); + if (c && el && el.clientWidth > 0) c.chart.applyOptions({ width: el.clientWidth }); + } +}); + +$("logs-reload").onclick = loadLogs; +$("stats-reload").onclick = loadStats; +$("news-reload").onclick = loadNews; +$("charts-reload").onclick = loadCharts; +// Auto-Refresh nur solange der Charts-Tab sichtbar ist (schont den mt5_lock) +setInterval(() => { if (!$("view-charts").classList.contains("hidden")) loadCharts(); }, 20000); + +// Sofort einmal per REST laden, dann auf den Live-Stream umschalten +fetch("api/snapshot").then(r => r.json()).then(_applySnapshot).catch(() => {}); +connect(); + +// ── Wake-up nach Sperrbildschirm/Hintergrund (Fix 2026-07-22: „G/V lag") ────── +// Mobile Browser pausieren WS+Timer beim Sperren/Backgrounding — die Seite zeigt +// bis zu einem Reconnect den STALEN Stand. Beim Zurückkommen (Tab/App wieder +// sichtbar) sofort per REST einen frischen Snapshot holen UND die WS-Verbindung +// hart neu aufbauen (statt auf den evtl. hängenden alten Socket zu warten). +function _wakeRefresh() { + fetch("api/snapshot").then(r => r.json()).then(_applySnapshot).catch(() => {}); + if (!sock || sock.readyState !== WebSocket.OPEN) { + clearTimeout(retry); + try { sock && sock.close(); } catch (e) {} + connect(); + } +} +document.addEventListener("visibilitychange", () => { + if (!document.hidden) _wakeRefresh(); +}); +window.addEventListener("pageshow", _wakeRefresh); +window.addEventListener("focus", _wakeRefresh); diff --git a/web/icon.svg b/web/icon.svg new file mode 100644 index 0000000..e8c4e99 --- /dev/null +++ b/web/icon.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..c0c1030 --- /dev/null +++ b/web/index.html @@ -0,0 +1,221 @@ + + + + + + + Oil · MT5 + + + + + + + +
    +
    + +
    +
    + + +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + + +
    +

    Gesamtempfehlung

    +
    +
    +
    +
    ◌ —
    +
    +
    +
    +
    + SHORT +
    + LONG +
    +
    + +
    +
    + + +
    +

    Meldungen

    +
    +
    +
    + +
    +
    Setup
    +
    Lauf
    +
    + + +
    +

    Marktstruktur M30

    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    Kanal untenoben
    +
    +
    + + +
    +

    KI-Copilot

    +
    ◌ —
    +
    +
    +
      +
      + + + + + +
      +

      Letzte Trades

      +
      +
      + + +
      +

      Kurslücken (Gaps 2026)

      +
      lade …
      +
      + + +
      +

      Statistik (live)

      +
      lade …
      +
      +
      + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/lightweight-charts.standalone.production.js b/web/lightweight-charts.standalone.production.js new file mode 100644 index 0000000..ab27dd4 --- /dev/null +++ b/web/lightweight-charts.standalone.production.js @@ -0,0 +1,7 @@ +/*! + * @license + * TradingView Lightweight Charts™ v4.2.3 + * Copyright (c) 2025 TradingView, Inc. + * Licensed under Apache License 2.0 https://www.apache.org/licenses/LICENSE-2.0 + */ +!function(){"use strict";const t={upColor:"#26a69a",downColor:"#ef5350",wickVisible:!0,borderVisible:!0,borderColor:"#378658",borderUpColor:"#26a69a",borderDownColor:"#ef5350",wickColor:"#737375",wickUpColor:"#26a69a",wickDownColor:"#ef5350"},i={upColor:"#26a69a",downColor:"#ef5350",openVisible:!0,thinBars:!0},n={color:"#2196f3",lineStyle:0,lineWidth:3,lineType:0,lineVisible:!0,crosshairMarkerVisible:!0,crosshairMarkerRadius:4,crosshairMarkerBorderColor:"",crosshairMarkerBorderWidth:2,crosshairMarkerBackgroundColor:"",lastPriceAnimation:0,pointMarkersVisible:!1},s={topColor:"rgba( 46, 220, 135, 0.4)",bottomColor:"rgba( 40, 221, 100, 0)",invertFilledArea:!1,lineColor:"#33D778",lineStyle:0,lineWidth:3,lineType:0,lineVisible:!0,crosshairMarkerVisible:!0,crosshairMarkerRadius:4,crosshairMarkerBorderColor:"",crosshairMarkerBorderWidth:2,crosshairMarkerBackgroundColor:"",lastPriceAnimation:0,pointMarkersVisible:!1},e={baseValue:{type:"price",price:0},topFillColor1:"rgba(38, 166, 154, 0.28)",topFillColor2:"rgba(38, 166, 154, 0.05)",topLineColor:"rgba(38, 166, 154, 1)",bottomFillColor1:"rgba(239, 83, 80, 0.05)",bottomFillColor2:"rgba(239, 83, 80, 0.28)",bottomLineColor:"rgba(239, 83, 80, 1)",lineWidth:3,lineStyle:0,lineType:0,lineVisible:!0,crosshairMarkerVisible:!0,crosshairMarkerRadius:4,crosshairMarkerBorderColor:"",crosshairMarkerBorderWidth:2,crosshairMarkerBackgroundColor:"",lastPriceAnimation:0,pointMarkersVisible:!1},r={color:"#26a69a",base:0},h={color:"#2196f3"},l={title:"",visible:!0,lastValueVisible:!0,priceLineVisible:!0,priceLineSource:0,priceLineWidth:1,priceLineColor:"",priceLineStyle:2,baseLineVisible:!0,baseLineWidth:1,baseLineColor:"#B2B5BE",baseLineStyle:0,priceFormat:{type:"price",precision:2,minMove:.01}};var a,o;function _(t,i){const n={0:[],1:[t.lineWidth,t.lineWidth],2:[2*t.lineWidth,2*t.lineWidth],3:[6*t.lineWidth,6*t.lineWidth],4:[t.lineWidth,4*t.lineWidth]}[i];t.setLineDash(n)}function u(t,i,n,s){t.beginPath();const e=t.lineWidth%2?.5:0;t.moveTo(n,i+e),t.lineTo(s,i+e),t.stroke()}function c(t,i){if(!t)throw new Error("Assertion failed"+(i?": "+i:""))}function d(t){if(void 0===t)throw new Error("Value is undefined");return t}function f(t){if(null===t)throw new Error("Value is null");return t}function v(t){return f(d(t))}!function(t){t[t.Simple=0]="Simple",t[t.WithSteps=1]="WithSteps",t[t.Curved=2]="Curved"}(a||(a={})),function(t){t[t.Solid=0]="Solid",t[t.Dotted=1]="Dotted",t[t.Dashed=2]="Dashed",t[t.LargeDashed=3]="LargeDashed",t[t.SparseDotted=4]="SparseDotted"}(o||(o={}));const p={khaki:"#f0e68c",azure:"#f0ffff",aliceblue:"#f0f8ff",ghostwhite:"#f8f8ff",gold:"#ffd700",goldenrod:"#daa520",gainsboro:"#dcdcdc",gray:"#808080",green:"#008000",honeydew:"#f0fff0",floralwhite:"#fffaf0",lightblue:"#add8e6",lightcoral:"#f08080",lemonchiffon:"#fffacd",hotpink:"#ff69b4",lightyellow:"#ffffe0",greenyellow:"#adff2f",lightgoldenrodyellow:"#fafad2",limegreen:"#32cd32",linen:"#faf0e6",lightcyan:"#e0ffff",magenta:"#f0f",maroon:"#800000",olive:"#808000",orange:"#ffa500",oldlace:"#fdf5e6",mediumblue:"#0000cd",transparent:"#0000",lime:"#0f0",lightpink:"#ffb6c1",mistyrose:"#ffe4e1",moccasin:"#ffe4b5",midnightblue:"#191970",orchid:"#da70d6",mediumorchid:"#ba55d3",mediumturquoise:"#48d1cc",orangered:"#ff4500",royalblue:"#4169e1",powderblue:"#b0e0e6",red:"#f00",coral:"#ff7f50",turquoise:"#40e0d0",white:"#fff",whitesmoke:"#f5f5f5",wheat:"#f5deb3",teal:"#008080",steelblue:"#4682b4",bisque:"#ffe4c4",aquamarine:"#7fffd4",aqua:"#0ff",sienna:"#a0522d",silver:"#c0c0c0",springgreen:"#00ff7f",antiquewhite:"#faebd7",burlywood:"#deb887",brown:"#a52a2a",beige:"#f5f5dc",chocolate:"#d2691e",chartreuse:"#7fff00",cornflowerblue:"#6495ed",cornsilk:"#fff8dc",crimson:"#dc143c",cadetblue:"#5f9ea0",tomato:"#ff6347",fuchsia:"#f0f",blue:"#00f",salmon:"#fa8072",blanchedalmond:"#ffebcd",slateblue:"#6a5acd",slategray:"#708090",thistle:"#d8bfd8",tan:"#d2b48c",cyan:"#0ff",darkblue:"#00008b",darkcyan:"#008b8b",darkgoldenrod:"#b8860b",darkgray:"#a9a9a9",blueviolet:"#8a2be2",black:"#000",darkmagenta:"#8b008b",darkslateblue:"#483d8b",darkkhaki:"#bdb76b",darkorchid:"#9932cc",darkorange:"#ff8c00",darkgreen:"#006400",darkred:"#8b0000",dodgerblue:"#1e90ff",darkslategray:"#2f4f4f",dimgray:"#696969",deepskyblue:"#00bfff",firebrick:"#b22222",forestgreen:"#228b22",indigo:"#4b0082",ivory:"#fffff0",lavenderblush:"#fff0f5",feldspar:"#d19275",indianred:"#cd5c5c",lightgreen:"#90ee90",lightgrey:"#d3d3d3",lightskyblue:"#87cefa",lightslategray:"#789",lightslateblue:"#8470ff",snow:"#fffafa",lightseagreen:"#20b2aa",lightsalmon:"#ffa07a",darksalmon:"#e9967a",darkviolet:"#9400d3",mediumpurple:"#9370d8",mediumaquamarine:"#66cdaa",skyblue:"#87ceeb",lavender:"#e6e6fa",lightsteelblue:"#b0c4de",mediumvioletred:"#c71585",mintcream:"#f5fffa",navajowhite:"#ffdead",navy:"#000080",olivedrab:"#6b8e23",palevioletred:"#d87093",violetred:"#d02090",yellow:"#ff0",yellowgreen:"#9acd32",lawngreen:"#7cfc00",pink:"#ffc0cb",paleturquoise:"#afeeee",palegoldenrod:"#eee8aa",darkolivegreen:"#556b2f",darkseagreen:"#8fbc8f",darkturquoise:"#00ced1",peachpuff:"#ffdab9",deeppink:"#ff1493",violet:"#ee82ee",palegreen:"#98fb98",mediumseagreen:"#3cb371",peru:"#cd853f",saddlebrown:"#8b4513",sandybrown:"#f4a460",rosybrown:"#bc8f8f",purple:"#800080",seagreen:"#2e8b57",seashell:"#fff5ee",papayawhip:"#ffefd5",mediumslateblue:"#7b68ee",plum:"#dda0dd",mediumspringgreen:"#00fa9a"};function m(t){return t<0?0:t>255?255:Math.round(t)||0}function b(t){return t<=0||t>1?Math.min(Math.max(t,0),1):Math.round(1e4*t)/1e4}const w=/^#([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])?$/i,g=/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})?$/i,M=/^rgb\(\s*(-?\d{1,10})\s*,\s*(-?\d{1,10})\s*,\s*(-?\d{1,10})\s*\)$/,x=/^rgba\(\s*(-?\d{1,10})\s*,\s*(-?\d{1,10})\s*,\s*(-?\d{1,10})\s*,\s*(-?\d*\.?\d+)\s*\)$/;function S(t){(t=t.toLowerCase())in p&&(t=p[t]);{const i=x.exec(t)||M.exec(t);if(i)return[m(parseInt(i[1],10)),m(parseInt(i[2],10)),m(parseInt(i[3],10)),b(i.length<5?1:parseFloat(i[4]))]}{const i=g.exec(t);if(i)return[m(parseInt(i[1],16)),m(parseInt(i[2],16)),m(parseInt(i[3],16)),1]}{const i=w.exec(t);if(i)return[m(17*parseInt(i[1],16)),m(17*parseInt(i[2],16)),m(17*parseInt(i[3],16)),1]}throw new Error(`Cannot parse color: ${t}`)}function k(t){return.199*t[0]+.687*t[1]+.114*t[2]}function y(t){const i=S(t);return{t:`rgb(${i[0]}, ${i[1]}, ${i[2]})`,i:k(i)>160?"black":"white"}}class C{constructor(){this.h=[]}l(t,i,n){const s={o:t,_:i,u:!0===n};this.h.push(s)}v(t){const i=this.h.findIndex((i=>t===i.o));i>-1&&this.h.splice(i,1)}p(t){this.h=this.h.filter((i=>i._!==t))}m(t,i,n){const s=[...this.h];this.h=this.h.filter((t=>!t.u)),s.forEach((s=>s.o(t,i,n)))}M(){return this.h.length>0}S(){this.h=[]}}function T(t,...i){for(const n of i)for(const i in n)void 0!==n[i]&&Object.prototype.hasOwnProperty.call(n,i)&&!["__proto__","constructor","prototype"].includes(i)&&("object"!=typeof n[i]||void 0===t[i]||Array.isArray(n[i])?t[i]=n[i]:T(t[i],n[i]));return t}function P(t){return"number"==typeof t&&isFinite(t)}function R(t){return"number"==typeof t&&t%1==0}function D(t){return"string"==typeof t}function O(t){return"boolean"==typeof t}function B(t){const i=t;if(!i||"object"!=typeof i)return i;let n,s,e;for(s in n=Array.isArray(i)?[]:{},i)i.hasOwnProperty(s)&&(e=i[s],n[s]=e&&"object"==typeof e?B(e):e);return n}function V(t){return null!==t}function A(t){return null===t?void 0:t}const z="-apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, Ubuntu, sans-serif";function E(t,i,n){return void 0===i&&(i=z),`${n=void 0!==n?`${n} `:""}${t}px ${i}`}class I{constructor(t){this.k={C:1,T:5,P:NaN,R:"",D:"",O:"",B:"",V:0,A:0,I:0,L:0,N:0},this.F=t}W(){const t=this.k,i=this.j(),n=this.H();return t.P===i&&t.D===n||(t.P=i,t.D=n,t.R=E(i,n),t.L=2.5/12*i,t.V=t.L,t.A=i/12*t.T,t.I=i/12*t.T,t.N=0),t.O=this.$(),t.B=this.U(),this.k}$(){return this.F.W().layout.textColor}U(){return this.F.q()}j(){return this.F.W().layout.fontSize}H(){return this.F.W().layout.fontFamily}}class L{constructor(){this.Y=[]}Z(t){this.Y=t}X(t,i,n){this.Y.forEach((s=>{s.X(t,i,n)}))}}class N{X(t,i,n){t.useBitmapCoordinateSpace((t=>this.K(t,i,n)))}}class F extends N{constructor(){super(...arguments),this.G=null}J(t){this.G=t}K({context:t,horizontalPixelRatio:i,verticalPixelRatio:n}){if(null===this.G||null===this.G.tt)return;const s=this.G.tt,e=this.G,r=Math.max(1,Math.floor(i))%2/2,h=h=>{t.beginPath();for(let l=s.to-1;l>=s.from;--l){const s=e.it[l],a=Math.round(s.nt*i)+r,o=s.st*n,_=h*n+r;t.moveTo(a,o),t.arc(a,o,_,0,2*Math.PI)}t.fill()};e.et>0&&(t.fillStyle=e.rt,h(e.ht+e.et)),t.fillStyle=e.lt,h(e.ht)}}function W(){return{it:[{nt:0,st:0,ot:0,_t:0}],lt:"",rt:"",ht:0,et:0,tt:null}}const j={from:0,to:1};class H{constructor(t,i){this.ut=new L,this.ct=[],this.dt=[],this.ft=!0,this.F=t,this.vt=i,this.ut.Z(this.ct)}bt(t){const i=this.F.wt();i.length!==this.ct.length&&(this.dt=i.map(W),this.ct=this.dt.map((t=>{const i=new F;return i.J(t),i})),this.ut.Z(this.ct)),this.ft=!0}gt(){return this.ft&&(this.Mt(),this.ft=!1),this.ut}Mt(){const t=2===this.vt.W().mode,i=this.F.wt(),n=this.vt.xt(),s=this.F.St();i.forEach(((i,e)=>{var r;const h=this.dt[e],l=i.kt(n);if(t||null===l||!i.yt())return void(h.tt=null);const a=f(i.Ct());h.lt=l.Tt,h.ht=l.ht,h.et=l.Pt,h.it[0]._t=l._t,h.it[0].st=i.Dt().Rt(l._t,a.Ot),h.rt=null!==(r=l.Bt)&&void 0!==r?r:this.F.Vt(h.it[0].st/i.Dt().At()),h.it[0].ot=n,h.it[0].nt=s.zt(n),h.tt=j}))}}class $ extends N{constructor(t){super(),this.Et=t}K({context:t,bitmapSize:i,horizontalPixelRatio:n,verticalPixelRatio:s}){if(null===this.Et)return;const e=this.Et.It.yt,r=this.Et.Lt.yt;if(!e&&!r)return;const h=Math.round(this.Et.nt*n),l=Math.round(this.Et.st*s);t.lineCap="butt",e&&h>=0&&(t.lineWidth=Math.floor(this.Et.It.et*n),t.strokeStyle=this.Et.It.O,t.fillStyle=this.Et.It.O,_(t,this.Et.It.Nt),function(t,i,n,s){t.beginPath();const e=t.lineWidth%2?.5:0;t.moveTo(i+e,n),t.lineTo(i+e,s),t.stroke()}(t,h,0,i.height)),r&&l>=0&&(t.lineWidth=Math.floor(this.Et.Lt.et*s),t.strokeStyle=this.Et.Lt.O,t.fillStyle=this.Et.Lt.O,_(t,this.Et.Lt.Nt),u(t,l,0,i.width))}}class U{constructor(t){this.ft=!0,this.Ft={It:{et:1,Nt:0,O:"",yt:!1},Lt:{et:1,Nt:0,O:"",yt:!1},nt:0,st:0},this.Wt=new $(this.Ft),this.jt=t}bt(){this.ft=!0}gt(){return this.ft&&(this.Mt(),this.ft=!1),this.Wt}Mt(){const t=this.jt.yt(),i=f(this.jt.Ht()),n=i.$t().W().crosshair,s=this.Ft;if(2===n.mode)return s.Lt.yt=!1,void(s.It.yt=!1);s.Lt.yt=t&&this.jt.Ut(i),s.It.yt=t&&this.jt.qt(),s.Lt.et=n.horzLine.width,s.Lt.Nt=n.horzLine.style,s.Lt.O=n.horzLine.color,s.It.et=n.vertLine.width,s.It.Nt=n.vertLine.style,s.It.O=n.vertLine.color,s.nt=this.jt.Yt(),s.st=this.jt.Zt()}}function q(t,i,n,s,e,r){t.fillRect(i+r,n,s-2*r,r),t.fillRect(i+r,n+e-r,s-2*r,r),t.fillRect(i,n,r,e),t.fillRect(i+s-r,n,r,e)}function Y(t,i,n,s,e,r){t.save(),t.globalCompositeOperation="copy",t.fillStyle=r,t.fillRect(i,n,s,e),t.restore()}function Z(t,i,n,s,e,r){t.beginPath(),t.roundRect?t.roundRect(i,n,s,e,r):(t.lineTo(i+s-r[1],n),0!==r[1]&&t.arcTo(i+s,n,i+s,n+r[1],r[1]),t.lineTo(i+s,n+e-r[2]),0!==r[2]&&t.arcTo(i+s,n+e,i+s-r[2],n+e,r[2]),t.lineTo(i+r[3],n+e),0!==r[3]&&t.arcTo(i,n+e,i,n+e-r[3],r[3]),t.lineTo(i,n+r[0]),0!==r[0]&&t.arcTo(i,n,i+r[0],n,r[0]))}function X(t,i,n,s,e,r,h=0,l=[0,0,0,0],a=""){if(t.save(),!h||!a||a===r)return Z(t,i,n,s,e,l),t.fillStyle=r,t.fill(),void t.restore();const o=h/2;var _;Z(t,i+o,n+o,s-h,e-h,(_=-o,l.map((t=>0===t?t:t+_)))),"transparent"!==r&&(t.fillStyle=r,t.fill()),"transparent"!==a&&(t.lineWidth=h,t.strokeStyle=a,t.closePath(),t.stroke()),t.restore()}function K(t,i,n,s,e,r,h){t.save(),t.globalCompositeOperation="copy";const l=t.createLinearGradient(0,0,0,e);l.addColorStop(0,r),l.addColorStop(1,h),t.fillStyle=l,t.fillRect(i,n,s,e),t.restore()}class G{constructor(t,i){this.J(t,i)}J(t,i){this.Et=t,this.Xt=i}At(t,i){return this.Et.yt?t.P+t.L+t.V:0}X(t,i,n,s){if(!this.Et.yt||0===this.Et.Kt.length)return;const e=this.Et.O,r=this.Xt.t,h=t.useBitmapCoordinateSpace((t=>{const h=t.context;h.font=i.R;const l=this.Gt(t,i,n,s),a=l.Jt;return l.Qt?X(h,a.ti,a.ii,a.ni,a.si,r,a.ei,[a.ht,0,0,a.ht],r):X(h,a.ri,a.ii,a.ni,a.si,r,a.ei,[0,a.ht,a.ht,0],r),this.Et.hi&&(h.fillStyle=e,h.fillRect(a.ri,a.li,a.ai-a.ri,a.oi)),this.Et._i&&(h.fillStyle=i.B,h.fillRect(l.Qt?a.ui-a.ei:0,a.ii,a.ei,a.ci-a.ii)),l}));t.useMediaCoordinateSpace((({context:t})=>{const n=h.di;t.font=i.R,t.textAlign=h.Qt?"right":"left",t.textBaseline="middle",t.fillStyle=e,t.fillText(this.Et.Kt,n.fi,(n.ii+n.ci)/2+n.pi)}))}Gt(t,i,n,s){var e;const{context:r,bitmapSize:h,mediaSize:l,horizontalPixelRatio:a,verticalPixelRatio:o}=t,_=this.Et.hi||!this.Et.mi?i.T:0,u=this.Et.bi?i.C:0,c=i.L+this.Xt.wi,d=i.V+this.Xt.gi,f=i.A,v=i.I,p=this.Et.Kt,m=i.P,b=n.Mi(r,p),w=Math.ceil(n.xi(r,p)),g=m+c+d,M=i.C+f+v+w+_,x=Math.max(1,Math.floor(o));let S=Math.round(g*o);S%2!=x%2&&(S+=1);const k=u>0?Math.max(1,Math.floor(u*a)):0,y=Math.round(M*a),C=Math.round(_*a),T=null!==(e=this.Xt.Si)&&void 0!==e?e:this.Xt.ki,P=Math.round(T*o)-Math.floor(.5*o),R=Math.floor(P+x/2-S/2),D=R+S,O="right"===s,B=O?l.width-u:u,V=O?h.width-k:k;let A,z,E;return O?(A=V-y,z=V-C,E=B-_-f-u):(A=V+y,z=V+C,E=B+_+f),{Qt:O,Jt:{ii:R,li:P,ci:D,ni:y,si:S,ht:2*a,ei:k,ti:A,ri:V,ai:z,oi:x,ui:h.width},di:{ii:R/o,ci:D/o,fi:E,pi:b}}}}class J{constructor(t){this.yi={ki:0,t:"#000",gi:0,wi:0},this.Ci={Kt:"",yt:!1,hi:!0,mi:!1,Bt:"",O:"#FFF",_i:!1,bi:!1},this.Ti={Kt:"",yt:!1,hi:!1,mi:!0,Bt:"",O:"#FFF",_i:!0,bi:!0},this.ft=!0,this.Pi=new(t||G)(this.Ci,this.yi),this.Ri=new(t||G)(this.Ti,this.yi)}Kt(){return this.Di(),this.Ci.Kt}ki(){return this.Di(),this.yi.ki}bt(){this.ft=!0}At(t,i=!1){return Math.max(this.Pi.At(t,i),this.Ri.At(t,i))}Oi(){return this.yi.Si||0}Bi(t){this.yi.Si=t}Vi(){return this.Di(),this.Ci.yt||this.Ti.yt}Ai(){return this.Di(),this.Ci.yt}gt(t){return this.Di(),this.Ci.hi=this.Ci.hi&&t.W().ticksVisible,this.Ti.hi=this.Ti.hi&&t.W().ticksVisible,this.Pi.J(this.Ci,this.yi),this.Ri.J(this.Ti,this.yi),this.Pi}zi(){return this.Di(),this.Pi.J(this.Ci,this.yi),this.Ri.J(this.Ti,this.yi),this.Ri}Di(){this.ft&&(this.Ci.hi=!0,this.Ti.hi=!1,this.Ei(this.Ci,this.Ti,this.yi))}}class Q extends J{constructor(t,i,n){super(),this.jt=t,this.Ii=i,this.Li=n}Ei(t,i,n){if(t.yt=!1,2===this.jt.W().mode)return;const s=this.jt.W().horzLine;if(!s.labelVisible)return;const e=this.Ii.Ct();if(!this.jt.yt()||this.Ii.Ni()||null===e)return;const r=y(s.labelBackgroundColor);n.t=r.t,t.O=r.i;const h=2/12*this.Ii.P();n.wi=h,n.gi=h;const l=this.Li(this.Ii);n.ki=l.ki,t.Kt=this.Ii.Fi(l._t,e),t.yt=!0}}const tt=/[1-9]/g;class it{constructor(){this.Et=null}J(t){this.Et=t}X(t,i){if(null===this.Et||!1===this.Et.yt||0===this.Et.Kt.length)return;const n=t.useMediaCoordinateSpace((({context:t})=>(t.font=i.R,Math.round(i.Wi.xi(t,f(this.Et).Kt,tt)))));if(n<=0)return;const s=i.ji,e=n+2*s,r=e/2,h=this.Et.Hi;let l=this.Et.ki,a=Math.floor(l-r)+.5;a<0?(l+=Math.abs(0-a),a=Math.floor(l-r)+.5):a+e>h&&(l-=Math.abs(h-(a+e)),a=Math.floor(l-r)+.5);const o=a+e,_=Math.ceil(0+i.C+i.T+i.L+i.P+i.V);t.useBitmapCoordinateSpace((({context:t,horizontalPixelRatio:n,verticalPixelRatio:s})=>{const e=f(this.Et);t.fillStyle=e.t;const r=Math.round(a*n),h=Math.round(0*s),l=Math.round(o*n),u=Math.round(_*s),c=Math.round(2*n);if(t.beginPath(),t.moveTo(r,h),t.lineTo(r,u-c),t.arcTo(r,u,r+c,u,c),t.lineTo(l-c,u),t.arcTo(l,u,l,u-c,c),t.lineTo(l,h),t.fill(),e.hi){const r=Math.round(e.ki*n),l=h,a=Math.round((l+i.T)*s);t.fillStyle=e.O;const o=Math.max(1,Math.floor(n)),_=Math.floor(.5*n);t.fillRect(r-_,l,o,a-l)}})),t.useMediaCoordinateSpace((({context:t})=>{const n=f(this.Et),e=0+i.C+i.T+i.L+i.P/2;t.font=i.R,t.textAlign="left",t.textBaseline="middle",t.fillStyle=n.O;const r=i.Wi.Mi(t,"Apr0");t.translate(a+s,e+r),t.fillText(n.Kt,0,0)}))}}class nt{constructor(t,i,n){this.ft=!0,this.Wt=new it,this.Ft={yt:!1,t:"#4c525e",O:"white",Kt:"",Hi:0,ki:NaN,hi:!0},this.vt=t,this.$i=i,this.Li=n}bt(){this.ft=!0}gt(){return this.ft&&(this.Mt(),this.ft=!1),this.Wt.J(this.Ft),this.Wt}Mt(){const t=this.Ft;if(t.yt=!1,2===this.vt.W().mode)return;const i=this.vt.W().vertLine;if(!i.labelVisible)return;const n=this.$i.St();if(n.Ni())return;t.Hi=n.Hi();const s=this.Li();if(null===s)return;t.ki=s.ki;const e=n.Ui(this.vt.xt());t.Kt=n.qi(f(e)),t.yt=!0;const r=y(i.labelBackgroundColor);t.t=r.t,t.O=r.i,t.hi=n.W().ticksVisible}}class st{constructor(){this.Yi=null,this.Zi=0}Xi(){return this.Zi}Ki(t){this.Zi=t}Dt(){return this.Yi}Gi(t){this.Yi=t}Ji(t){return[]}Qi(){return[]}yt(){return!0}}var et;!function(t){t[t.Normal=0]="Normal",t[t.Magnet=1]="Magnet",t[t.Hidden=2]="Hidden"}(et||(et={}));class rt extends st{constructor(t,i){super(),this.tn=null,this.nn=NaN,this.sn=0,this.en=!0,this.rn=new Map,this.hn=!1,this.ln=NaN,this.an=NaN,this._n=NaN,this.un=NaN,this.$i=t,this.cn=i,this.dn=new H(t,this);this.fn=((t,i)=>n=>{const s=i(),e=t();if(n===f(this.tn).vn())return{_t:e,ki:s};{const t=f(n.Ct());return{_t:n.pn(s,t),ki:s}}})((()=>this.nn),(()=>this.an));const n=((t,i)=>()=>{const n=this.$i.St().mn(t()),s=i();return n&&Number.isFinite(s)?{ot:n,ki:s}:null})((()=>this.sn),(()=>this.Yt()));this.bn=new nt(this,t,n),this.wn=new U(this)}W(){return this.cn}gn(t,i){this._n=t,this.un=i}Mn(){this._n=NaN,this.un=NaN}xn(){return this._n}Sn(){return this.un}kn(t,i,n){this.hn||(this.hn=!0),this.en=!0,this.yn(t,i,n)}xt(){return this.sn}Yt(){return this.ln}Zt(){return this.an}yt(){return this.en}Cn(){this.en=!1,this.Tn(),this.nn=NaN,this.ln=NaN,this.an=NaN,this.tn=null,this.Mn()}Pn(t){return null!==this.tn?[this.wn,this.dn]:[]}Ut(t){return t===this.tn&&this.cn.horzLine.visible}qt(){return this.cn.vertLine.visible}Rn(t,i){this.en&&this.tn===t||this.rn.clear();const n=[];return this.tn===t&&n.push(this.Dn(this.rn,i,this.fn)),n}Qi(){return this.en?[this.bn]:[]}Ht(){return this.tn}On(){this.wn.bt(),this.rn.forEach((t=>t.bt())),this.bn.bt(),this.dn.bt()}Bn(t){return t&&!t.vn().Ni()?t.vn():null}yn(t,i,n){this.Vn(t,i,n)&&this.On()}Vn(t,i,n){const s=this.ln,e=this.an,r=this.nn,h=this.sn,l=this.tn,a=this.Bn(n);this.sn=t,this.ln=isNaN(t)?NaN:this.$i.St().zt(t),this.tn=n;const o=null!==a?a.Ct():null;return null!==a&&null!==o?(this.nn=i,this.an=a.Rt(i,o)):(this.nn=NaN,this.an=NaN),s!==this.ln||e!==this.an||h!==this.sn||r!==this.nn||l!==this.tn}Tn(){const t=this.$i.wt().map((t=>t.zn().An())).filter(V),i=0===t.length?null:Math.max(...t);this.sn=null!==i?i:NaN}Dn(t,i,n){let s=t.get(i);return void 0===s&&(s=new Q(this,i,n),t.set(i,s)),s}}function ht(t){return"left"===t||"right"===t}class lt{constructor(t){this.En=new Map,this.In=[],this.Ln=t}Nn(t,i){const n=function(t,i){return void 0===t?i:{Fn:Math.max(t.Fn,i.Fn),Wn:t.Wn||i.Wn}}(this.En.get(t),i);this.En.set(t,n)}jn(){return this.Ln}Hn(t){const i=this.En.get(t);return void 0===i?{Fn:this.Ln}:{Fn:Math.max(this.Ln,i.Fn),Wn:i.Wn}}$n(){this.Un(),this.In=[{qn:0}]}Yn(t){this.Un(),this.In=[{qn:1,Ot:t}]}Zn(t){this.Xn(),this.In.push({qn:5,Ot:t})}Un(){this.Xn(),this.In.push({qn:6})}Kn(){this.Un(),this.In=[{qn:4}]}Gn(t){this.Un(),this.In.push({qn:2,Ot:t})}Jn(t){this.Un(),this.In.push({qn:3,Ot:t})}Qn(){return this.In}ts(t){for(const i of t.In)this.ns(i);this.Ln=Math.max(this.Ln,t.Ln),t.En.forEach(((t,i)=>{this.Nn(i,t)}))}static ss(){return new lt(2)}static es(){return new lt(3)}ns(t){switch(t.qn){case 0:this.$n();break;case 1:this.Yn(t.Ot);break;case 2:this.Gn(t.Ot);break;case 3:this.Jn(t.Ot);break;case 4:this.Kn();break;case 5:this.Zn(t.Ot);break;case 6:this.Xn()}}Xn(){const t=this.In.findIndex((t=>5===t.qn));-1!==t&&this.In.splice(t,1)}}const at=".";function ot(t,i){if(!P(t))return"n/a";if(!R(i))throw new TypeError("invalid length");if(i<0||i>16)throw new TypeError("invalid length");if(0===i)return t.toString();return("0000000000000000"+t.toString()).slice(-i)}class _t{constructor(t,i){if(i||(i=1),P(t)&&R(t)||(t=100),t<0)throw new TypeError("invalid base");this.Ii=t,this.rs=i,this.hs()}format(t){const i=t<0?"−":"";return t=Math.abs(t),i+this.ls(t)}hs(){if(this._s=0,this.Ii>0&&this.rs>0){let t=this.Ii;for(;t>1;)t/=10,this._s++}}ls(t){const i=this.Ii/this.rs;let n=Math.floor(t),s="";const e=void 0!==this._s?this._s:NaN;if(i>1){let r=+(Math.round(t*i)-n*i).toFixed(this._s);r>=i&&(r-=i,n+=1),s=at+ot(+r.toFixed(this._s)*this.rs,e)}else n=Math.round(n*i)/i,e>0&&(s=at+ot(0,e));return n.toFixed(0)+s}}class ut extends _t{constructor(t=100){super(t)}format(t){return`${super.format(t)}%`}}class ct{constructor(t){this.us=t}format(t){let i="";return t<0&&(i="-",t=-t),t<995?i+this.cs(t):t<999995?i+this.cs(t/1e3)+"K":t<999999995?(t=1e3*Math.round(t/1e3),i+this.cs(t/1e6)+"M"):(t=1e6*Math.round(t/1e6),i+this.cs(t/1e9)+"B")}cs(t){let i;const n=Math.pow(10,this.us);return i=(t=Math.round(t*n)/n)>=1e-15&&t<1?t.toFixed(this.us).replace(/\.?0+$/,""):String(t),i.replace(/(\.[1-9]*)0+$/,((t,i)=>i))}}function dt(t,i,n,s,e,r,h){if(0===i.length||s.from>=i.length||s.to<=0)return;const{context:l,horizontalPixelRatio:a,verticalPixelRatio:o}=t,_=i[s.from];let u=r(t,_),c=_;if(s.to-s.from<2){const i=e/2;l.beginPath();const n={nt:_.nt-i,st:_.st},s={nt:_.nt+i,st:_.st};l.moveTo(n.nt*a,n.st*o),l.lineTo(s.nt*a,s.st*o),h(t,u,n,s)}else{const e=(i,n)=>{h(t,u,c,n),l.beginPath(),u=i,c=n};let d=c;l.beginPath(),l.moveTo(_.nt*a,_.st*o);for(let h=s.from+1;h=s.from;--n){const s=i[n];if(s){const i=e(t,s);i!==a&&(l.beginPath(),null!==a&&l.fill(),l.fillStyle=i,a=i);const n=Math.round(s.nt*r)+o,u=s.st*h;l.moveTo(n,u),l.arc(n,u,_,0,2*Math.PI)}}l.fill()}(t,i,l,n,o)}}class Tt extends Ct{Os(t,i){return i.lt}}function Pt(t,i,n,s,e=0,r=i.length){let h=r-e;for(;0>1,l=e+r;s(i[l],n)===t?(e=l+1,h-=r+1):h=r}return e}const Rt=Pt.bind(null,!0),Dt=Pt.bind(null,!1);function Ot(t,i){return t.ot0&&r=s&&(l=r-1),h>0&&hObject.assign(Object.assign({},t),this.Ls.Us().$s(t.ot))))}qs(){this.Is=null}Ws(){this.As&&(this.Ys(),this.As=!1),this.zs&&(this.Hs(),this.zs=!1),this.Vs&&(this.Zs(),this.Vs=!1)}Zs(){const t=this.Ls.Dt(),i=this.Ns.St();if(this.qs(),i.Ni()||t.Ni())return;const n=i.Xs();if(null===n)return;if(0===this.Ls.zn().Ks())return;const s=this.Ls.Ct();null!==s&&(this.Is=Vt(this.Es,n,this.Fs),this.Gs(t,i,s.Ot),this.Js())}}class zt extends At{constructor(t,i){super(t,i,!0)}Gs(t,i,n){i.Qs(this.Es,A(this.Is)),t.te(this.Es,n,A(this.Is))}ie(t,i){return{ot:t,_t:i,nt:NaN,st:NaN}}Ys(){const t=this.Ls.Us();this.Es=this.Ls.zn().ne().map((i=>{const n=i.Ot[3];return this.se(i.ee,n,t)}))}}class Et extends zt{constructor(t,i){super(t,i),this.js=new L,this.re=new kt,this.he=new Tt,this.js.Z([this.re,this.he])}se(t,i,n){return Object.assign(Object.assign({},this.ie(t,i)),n.$s(t))}Js(){const t=this.Ls.W();this.re.J({fs:t.lineType,it:this.Es,Nt:t.lineStyle,et:t.lineWidth,vs:null,ps:t.invertFilledArea,tt:this.Is,ds:this.Ns.St().le()}),this.he.J({fs:t.lineVisible?t.lineType:void 0,it:this.Es,Nt:t.lineStyle,et:t.lineWidth,tt:this.Is,ds:this.Ns.St().le(),Ds:t.pointMarkersVisible?t.pointMarkersRadius||t.lineWidth/2+2:void 0})}}class It extends N{constructor(){super(...arguments),this.Et=null,this.ae=0,this.oe=0}J(t){this.Et=t}K({context:t,horizontalPixelRatio:i,verticalPixelRatio:n}){if(null===this.Et||0===this.Et.zn.length||null===this.Et.tt)return;if(this.ae=this._e(i),this.ae>=2){Math.max(1,Math.floor(i))%2!=this.ae%2&&this.ae--}this.oe=this.Et.ue?Math.min(this.ae,Math.floor(i)):this.ae;let s=null;const e=this.oe<=this.ae&&this.Et.le>=Math.floor(1.5*i);for(let r=this.Et.tt.from;rf+p-1&&(e=f+p-1,s=e-_+1),t.fillRect(i,s,o-i,e-s+1)}const i=a+m;let s=Math.max(f,Math.round(h.me*n)-l),e=s+_-1;e>f+p-1&&(e=f+p-1,s=e-_+1),t.fillRect(u+1,s,i-u,e-s+1)}}}_e(t){const i=Math.floor(t);return Math.max(i,Math.floor(function(t,i){return Math.floor(.3*t*i)}(f(this.Et).le,t)))}}class Lt extends At{constructor(t,i){super(t,i,!1)}Gs(t,i,n){i.Qs(this.Es,A(this.Is)),t.be(this.Es,n,A(this.Is))}we(t,i,n){return{ot:t,ge:i.Ot[0],Me:i.Ot[1],xe:i.Ot[2],Se:i.Ot[3],nt:NaN,pe:NaN,de:NaN,fe:NaN,me:NaN}}Ys(){const t=this.Ls.Us();this.Es=this.Ls.zn().ne().map((i=>this.se(i.ee,i,t)))}}class Nt extends Lt{constructor(){super(...arguments),this.js=new It}se(t,i,n){return Object.assign(Object.assign({},this.we(t,i,n)),n.$s(t))}Js(){const t=this.Ls.W();this.js.J({zn:this.Es,le:this.Ns.St().le(),ve:t.openVisible,ue:t.thinBars,tt:this.Is})}}class Ft extends wt{constructor(){super(...arguments),this.Ts=new St}bs(t,i){const n=this.G;return this.Ts.ws(t,{Ms:i.ke,xs:i.ye,Ss:i.Ce,ks:i.Te,ys:t.bitmapSize.height,vs:n.vs})}}class Wt extends Ct{constructor(){super(...arguments),this.Pe=new St}Os(t,i){const n=this.G;return this.Pe.ws(t,{Ms:i.Re,xs:i.Re,Ss:i.De,ks:i.De,ys:t.bitmapSize.height,vs:n.vs})}}class jt extends zt{constructor(t,i){super(t,i),this.js=new L,this.Oe=new Ft,this.Be=new Wt,this.js.Z([this.Oe,this.Be])}se(t,i,n){return Object.assign(Object.assign({},this.ie(t,i)),n.$s(t))}Js(){const t=this.Ls.Ct();if(null===t)return;const i=this.Ls.W(),n=this.Ls.Dt().Rt(i.baseValue.price,t.Ot),s=this.Ns.St().le();this.Oe.J({it:this.Es,et:i.lineWidth,Nt:i.lineStyle,fs:i.lineType,vs:n,ps:!1,tt:this.Is,ds:s}),this.Be.J({it:this.Es,et:i.lineWidth,Nt:i.lineStyle,fs:i.lineVisible?i.lineType:void 0,Ds:i.pointMarkersVisible?i.pointMarkersRadius||i.lineWidth/2+2:void 0,vs:n,tt:this.Is,ds:s})}}class Ht extends N{constructor(){super(...arguments),this.Et=null,this.ae=0}J(t){this.Et=t}K(t){if(null===this.Et||0===this.Et.zn.length||null===this.Et.tt)return;const{horizontalPixelRatio:i}=t;if(this.ae=function(t,i){if(t>=2.5&&t<=4)return Math.floor(3*i);const n=1-.2*Math.atan(Math.max(4,t)-4)/(.5*Math.PI),s=Math.floor(t*n*i),e=Math.floor(t*i),r=Math.min(s,e);return Math.max(Math.floor(i),r)}(this.Et.le,i),this.ae>=2){Math.floor(i)%2!=this.ae%2&&this.ae--}const n=this.Et.zn;this.Et.Ve&&this.Ae(t,n,this.Et.tt),this.Et._i&&this.ze(t,n,this.Et.tt);const s=this.Ee(i);(!this.Et._i||this.ae>2*s)&&this.Ie(t,n,this.Et.tt)}Ae(t,i,n){if(null===this.Et)return;const{context:s,horizontalPixelRatio:e,verticalPixelRatio:r}=t;let h="",l=Math.min(Math.floor(e),Math.floor(this.Et.le*e));l=Math.max(Math.floor(e),Math.min(l,this.ae));const a=Math.floor(.5*l);let o=null;for(let t=n.from;t2*l)q(s,o,u,_-o+1,c-u+1,l);else{const t=_-o+1;s.fillRect(o,u,t,c-u+1)}a=_}}Ie(t,i,n){if(null===this.Et)return;const{context:s,horizontalPixelRatio:e,verticalPixelRatio:r}=t;let h="";const l=this.Ee(e);for(let t=n.from;to||s.fillRect(_,a,u-_+1,o-a+1)}}}class $t extends Lt{constructor(){super(...arguments),this.js=new Ht}se(t,i,n){return Object.assign(Object.assign({},this.we(t,i,n)),n.$s(t))}Js(){const t=this.Ls.W();this.js.J({zn:this.Es,le:this.Ns.St().le(),Ve:t.wickVisible,_i:t.borderVisible,tt:this.Is})}}class Ut{constructor(t,i){this.Fe=t,this.Ii=i}X(t,i,n){this.Fe.draw(t,this.Ii,i,n)}}class qt extends At{constructor(t,i,n){super(t,i,!1),this.wn=n,this.js=new Ut(this.wn.renderer(),(i=>{const n=t.Ct();return null===n?null:t.Dt().Rt(i,n.Ot)}))}We(t){return this.wn.priceValueBuilder(t)}je(t){return this.wn.isWhitespace(t)}Ys(){const t=this.Ls.Us();this.Es=this.Ls.zn().ne().map((i=>Object.assign(Object.assign({ot:i.ee,nt:NaN},t.$s(i.ee)),{He:i.$e})))}Gs(t,i){i.Qs(this.Es,A(this.Is))}Js(){this.wn.update({bars:this.Es.map(Yt),barSpacing:this.Ns.St().le(),visibleRange:this.Is},this.Ls.W())}}function Yt(t){return{x:t.nt,time:t.ot,originalData:t.He,barColor:t.ce}}class Zt extends N{constructor(){super(...arguments),this.Et=null,this.Ue=[]}J(t){this.Et=t,this.Ue=[]}K({context:t,horizontalPixelRatio:i,verticalPixelRatio:n}){if(null===this.Et||0===this.Et.it.length||null===this.Et.tt)return;this.Ue.length||this.qe(i);const s=Math.max(1,Math.floor(n)),e=Math.round(this.Et.Ye*n)-Math.floor(s/2),r=e+s;for(let i=this.Et.tt.from;is.Xe?s.ui=n.Bs-i-1:n.Bs=s.ui+i+1))}let s=Math.ceil(this.Et.le*t);for(let t=this.Et.tt.from;t0&&s<4)for(let t=this.Et.tt.from;ts&&(i.Ze>i.Xe?i.ui-=1:i.Bs+=1)}}}class Xt extends zt{constructor(){super(...arguments),this.js=new Zt}se(t,i,n){return Object.assign(Object.assign({},this.ie(t,i)),n.$s(t))}Js(){const t={it:this.Es,le:this.Ns.St().le(),tt:this.Is,Ye:this.Ls.Dt().Rt(this.Ls.W().base,f(this.Ls.Ct()).Ot)};this.js.J(t)}}class Kt extends zt{constructor(){super(...arguments),this.js=new Tt}se(t,i,n){return Object.assign(Object.assign({},this.ie(t,i)),n.$s(t))}Js(){const t=this.Ls.W(),i={it:this.Es,Nt:t.lineStyle,fs:t.lineVisible?t.lineType:void 0,et:t.lineWidth,Ds:t.pointMarkersVisible?t.pointMarkersRadius||t.lineWidth/2+2:void 0,tt:this.Is,ds:this.Ns.St().le()};this.js.J(i)}}const Gt=/[2-9]/g;class Jt{constructor(t=50){this.Ke=0,this.Ge=1,this.Je=1,this.Qe={},this.tr=new Map,this.ir=t}nr(){this.Ke=0,this.tr.clear(),this.Ge=1,this.Je=1,this.Qe={}}xi(t,i,n){return this.sr(t,i,n).width}Mi(t,i,n){const s=this.sr(t,i,n);return((s.actualBoundingBoxAscent||0)-(s.actualBoundingBoxDescent||0))/2}sr(t,i,n){const s=n||Gt,e=String(i).replace(s,"0");if(this.tr.has(e))return d(this.tr.get(e)).er;if(this.Ke===this.ir){const t=this.Qe[this.Je];delete this.Qe[this.Je],this.tr.delete(t),this.Je++,this.Ke--}t.save(),t.textBaseline="middle";const r=t.measureText(e);return t.restore(),0===r.width&&i.length||(this.tr.set(e,{er:r,rr:this.Ge}),this.Qe[this.Ge]=e,this.Ke++,this.Ge++),r}}class Qt{constructor(t){this.hr=null,this.k=null,this.lr="right",this.ar=t}_r(t,i,n){this.hr=t,this.k=i,this.lr=n}X(t){null!==this.k&&null!==this.hr&&this.hr.X(t,this.k,this.ar,this.lr)}}class ti{constructor(t,i,n){this.ur=t,this.ar=new Jt(50),this.cr=i,this.F=n,this.j=-1,this.Wt=new Qt(this.ar)}gt(){const t=this.F.dr(this.cr);if(null===t)return null;const i=t.vr(this.cr)?t.pr():this.cr.Dt();if(null===i)return null;const n=t.mr(i);if("overlay"===n)return null;const s=this.F.br();return s.P!==this.j&&(this.j=s.P,this.ar.nr()),this.Wt._r(this.ur.zi(),s,n),this.Wt}}class ii extends N{constructor(){super(...arguments),this.Et=null}J(t){this.Et=t}wr(t,i){var n;if(!(null===(n=this.Et)||void 0===n?void 0:n.yt))return null;const{st:s,et:e,gr:r}=this.Et;return i>=s-e-7&&i<=s+e+7?{Mr:this.Et,gr:r}:null}K({context:t,bitmapSize:i,horizontalPixelRatio:n,verticalPixelRatio:s}){if(null===this.Et)return;if(!1===this.Et.yt)return;const e=Math.round(this.Et.st*s);e<0||e>i.height||(t.lineCap="butt",t.strokeStyle=this.Et.O,t.lineWidth=Math.floor(this.Et.et*n),_(t,this.Et.Nt),u(t,e,0,i.width))}}class ni{constructor(t){this.Sr={st:0,O:"rgba(0, 0, 0, 0)",et:1,Nt:0,yt:!1},this.kr=new ii,this.ft=!0,this.Ls=t,this.Ns=t.$t(),this.kr.J(this.Sr)}bt(){this.ft=!0}gt(){return this.Ls.yt()?(this.ft&&(this.yr(),this.ft=!1),this.kr):null}}class si extends ni{constructor(t){super(t)}yr(){this.Sr.yt=!1;const t=this.Ls.Dt(),i=t.Cr().Cr;if(2!==i&&3!==i)return;const n=this.Ls.W();if(!n.baseLineVisible||!this.Ls.yt())return;const s=this.Ls.Ct();null!==s&&(this.Sr.yt=!0,this.Sr.st=t.Rt(s.Ot,s.Ot),this.Sr.O=n.baseLineColor,this.Sr.et=n.baseLineWidth,this.Sr.Nt=n.baseLineStyle)}}class ei extends N{constructor(){super(...arguments),this.Et=null}J(t){this.Et=t}$e(){return this.Et}K({context:t,horizontalPixelRatio:i,verticalPixelRatio:n}){const s=this.Et;if(null===s)return;const e=Math.max(1,Math.floor(i)),r=e%2/2,h=Math.round(s.Xe.x*i)+r,l=s.Xe.y*n;t.fillStyle=s.Tr,t.beginPath();const a=Math.max(2,1.5*s.Pr)*i;t.arc(h,l,a,0,2*Math.PI,!1),t.fill(),t.fillStyle=s.Rr,t.beginPath(),t.arc(h,l,s.ht*i,0,2*Math.PI,!1),t.fill(),t.lineWidth=e,t.strokeStyle=s.Dr,t.beginPath(),t.arc(h,l,s.ht*i+e/2,0,2*Math.PI,!1),t.stroke()}}const ri=[{Or:0,Br:.25,Vr:4,Ar:10,zr:.25,Er:0,Ir:.4,Lr:.8},{Or:.25,Br:.525,Vr:10,Ar:14,zr:0,Er:0,Ir:.8,Lr:0},{Or:.525,Br:1,Vr:14,Ar:14,zr:0,Er:0,Ir:0,Lr:0}];function hi(t,i,n,s){return function(t,i){if("transparent"===t)return t;const n=S(t),s=n[3];return`rgba(${n[0]}, ${n[1]}, ${n[2]}, ${i*s})`}(t,n+(s-n)*i)}function li(t,i){const n=t%2600/2600;let s;for(const t of ri)if(n>=t.Or&&n<=t.Br){s=t;break}c(void 0!==s,"Last price animation internal logic error");const e=(n-s.Or)/(s.Br-s.Or);return{Rr:hi(i,e,s.zr,s.Er),Dr:hi(i,e,s.Ir,s.Lr),ht:(r=e,h=s.Vr,l=s.Ar,h+(l-h)*r)};var r,h,l}class ai{constructor(t){this.Wt=new ei,this.ft=!0,this.Nr=!0,this.Fr=performance.now(),this.Wr=this.Fr-1,this.jr=t}Hr(){this.Wr=this.Fr-1,this.bt()}$r(){if(this.bt(),2===this.jr.W().lastPriceAnimation){const t=performance.now(),i=this.Wr-t;if(i>0)return void(i<650&&(this.Wr+=2600));this.Fr=t,this.Wr=t+2600}}bt(){this.ft=!0}Ur(){this.Nr=!0}yt(){return 0!==this.jr.W().lastPriceAnimation}qr(){switch(this.jr.W().lastPriceAnimation){case 0:return!1;case 1:return!0;case 2:return performance.now()<=this.Wr}}gt(){return this.ft?(this.Mt(),this.ft=!1,this.Nr=!1):this.Nr&&(this.Yr(),this.Nr=!1),this.Wt}Mt(){this.Wt.J(null);const t=this.jr.$t().St(),i=t.Xs(),n=this.jr.Ct();if(null===i||null===n)return;const s=this.jr.Zr(!0);if(s.Xr||!i.Kr(s.ee))return;const e={x:t.zt(s.ee),y:this.jr.Dt().Rt(s._t,n.Ot)},r=s.O,h=this.jr.W().lineWidth,l=li(this.Gr(),r);this.Wt.J({Tr:r,Pr:h,Rr:l.Rr,Dr:l.Dr,ht:l.ht,Xe:e})}Yr(){const t=this.Wt.$e();if(null!==t){const i=li(this.Gr(),t.Tr);t.Rr=i.Rr,t.Dr=i.Dr,t.ht=i.ht}}Gr(){return this.qr()?performance.now()-this.Fr:2599}}function oi(t,i){return xt(Math.min(Math.max(t,12),30)*i)}function _i(t,i){switch(t){case"arrowDown":case"arrowUp":return oi(i,1);case"circle":return oi(i,.8);case"square":return oi(i,.7)}}function ui(t){return function(t){const i=Math.ceil(t);return i%2!=0?i-1:i}(oi(t,1))}function ci(t){return Math.max(oi(t,.1),3)}function di(t,i,n){return i?t:n?Math.ceil(t/2):0}function fi(t,i,n,s,e){const r=_i("square",n),h=(r-1)/2,l=t-h,a=i-h;return s>=l&&s<=l+r&&e>=a&&e<=a+r}function vi(t,i,n,s){const e=(_i("arrowUp",s)-1)/2*n.Jr,r=(xt(s/2)-1)/2*n.Jr;i.beginPath(),t?(i.moveTo(n.nt-e,n.st),i.lineTo(n.nt,n.st-e),i.lineTo(n.nt+e,n.st),i.lineTo(n.nt+r,n.st),i.lineTo(n.nt+r,n.st+e),i.lineTo(n.nt-r,n.st+e),i.lineTo(n.nt-r,n.st)):(i.moveTo(n.nt-e,n.st),i.lineTo(n.nt,n.st+e),i.lineTo(n.nt+e,n.st),i.lineTo(n.nt+r,n.st),i.lineTo(n.nt+r,n.st-e),i.lineTo(n.nt-r,n.st-e),i.lineTo(n.nt-r,n.st)),i.fill()}function pi(t,i,n,s,e,r){return fi(i,n,s,e,r)}class mi extends N{constructor(){super(...arguments),this.Et=null,this.ar=new Jt,this.j=-1,this.H="",this.Qr=""}J(t){this.Et=t}_r(t,i){this.j===t&&this.H===i||(this.j=t,this.H=i,this.Qr=E(t,i),this.ar.nr())}wr(t,i){if(null===this.Et||null===this.Et.tt)return null;for(let n=this.Et.tt.from;n=t&&e<=t+n&&r>=i-h&&r<=i+h}(t.Kt.nt,t.Kt.st,t.Kt.Hi,t.Kt.At,i,n))||function(t,i,n){if(0===t.Ks)return!1;switch(t.nh){case"arrowDown":case"arrowUp":return pi(0,t.nt,t.st,t.Ks,i,n);case"circle":return function(t,i,n,s,e){const r=2+_i("circle",n)/2,h=t-s,l=i-e;return Math.sqrt(h*h+l*l)<=r}(t.nt,t.st,t.Ks,i,n);case"square":return fi(t.nt,t.st,t.Ks,i,n)}}(t,i,n)}function gi(t,i,n,s,e,r,h,l,a){const o=P(n)?n:n.Se,_=P(n)?n:n.Me,u=P(n)?n:n.xe,c=P(i.size)?Math.max(i.size,0):1,d=ui(l.le())*c,f=d/2;switch(t.Ks=d,i.position){case"inBar":return t.st=h.Rt(o,a),void(void 0!==t.Kt&&(t.Kt.st=t.st+f+r+.6*e));case"aboveBar":return t.st=h.Rt(_,a)-f-s.sh,void 0!==t.Kt&&(t.Kt.st=t.st-f-.6*e,s.sh+=1.2*e),void(s.sh+=d+r);case"belowBar":return t.st=h.Rt(u,a)+f+s.eh,void 0!==t.Kt&&(t.Kt.st=t.st+f+r+.6*e,s.eh+=1.2*e),void(s.eh+=d+r)}i.position}class Mi{constructor(t,i){this.ft=!0,this.rh=!0,this.hh=!0,this.ah=null,this.oh=null,this.Wt=new mi,this.jr=t,this.$i=i,this.Et={it:[],tt:null}}bt(t){this.ft=!0,this.hh=!0,"data"===t&&(this.rh=!0,this.oh=null)}gt(t){if(!this.jr.yt())return null;this.ft&&this._h();const i=this.$i.W().layout;return this.Wt._r(i.fontSize,i.fontFamily),this.Wt.J(this.Et),this.Wt}uh(){if(this.hh){if(this.jr.dh().length>0){const t=this.$i.St().le(),i=ci(t),n=1.5*ui(t)+2*i,s=this.fh();this.ah={above:di(n,s.aboveBar,s.inBar),below:di(n,s.belowBar,s.inBar)}}else this.ah=null;this.hh=!1}return this.ah}fh(){return null===this.oh&&(this.oh=this.jr.dh().reduce(((t,i)=>(t[i.position]||(t[i.position]=!0),t)),{inBar:!1,aboveBar:!1,belowBar:!1})),this.oh}_h(){const t=this.jr.Dt(),i=this.$i.St(),n=this.jr.dh();this.rh&&(this.Et.it=n.map((t=>({ot:t.time,nt:0,st:0,Ks:0,nh:t.shape,O:t.color,th:t.th,gr:t.id,Kt:void 0}))),this.rh=!1);const s=this.$i.W().layout;this.Et.tt=null;const e=i.Xs();if(null===e)return;const r=this.jr.Ct();if(null===r)return;if(0===this.Et.it.length)return;let h=NaN;const l=ci(i.le()),a={sh:l,eh:l};this.Et.tt=Vt(this.Et.it,e,!0);for(let e=this.Et.tt.from;e0&&(_.Kt={ih:o.text,nt:0,st:0,Hi:0,At:0});const u=this.jr.ph(o.time);null!==u&&gi(_,o,u,a,s.fontSize,l,t,i,r.Ot)}this.ft=!1}}class xi extends ni{constructor(t){super(t)}yr(){const t=this.Sr;t.yt=!1;const i=this.Ls.W();if(!i.priceLineVisible||!this.Ls.yt())return;const n=this.Ls.Zr(0===i.priceLineSource);n.Xr||(t.yt=!0,t.st=n.ki,t.O=this.Ls.mh(n.O),t.et=i.priceLineWidth,t.Nt=i.priceLineStyle)}}class Si extends J{constructor(t){super(),this.jt=t}Ei(t,i,n){t.yt=!1,i.yt=!1;const s=this.jt;if(!s.yt())return;const e=s.W(),r=e.lastValueVisible,h=""!==s.bh(),l=0===e.seriesLastValueMode,a=s.Zr(!1);if(a.Xr)return;r&&(t.Kt=this.wh(a,r,l),t.yt=0!==t.Kt.length),(h||l)&&(i.Kt=this.gh(a,r,h,l),i.yt=i.Kt.length>0);const o=s.mh(a.O),_=y(o);n.t=_.t,n.ki=a.ki,i.Bt=s.$t().Vt(a.ki/s.Dt().At()),t.Bt=o,t.O=_.i,i.O=_.i}gh(t,i,n,s){let e="";const r=this.jt.bh();return n&&0!==r.length&&(e+=`${r} `),i&&s&&(e+=this.jt.Dt().Mh()?t.xh:t.Sh),e.trim()}wh(t,i,n){return i?n?this.jt.Dt().Mh()?t.Sh:t.xh:t.Kt:""}}function ki(t,i,n,s){const e=Number.isFinite(i),r=Number.isFinite(n);return e&&r?t(i,n):e||r?e?i:n:s}class yi{constructor(t,i){this.kh=t,this.yh=i}Ch(t){return null!==t&&(this.kh===t.kh&&this.yh===t.yh)}Th(){return new yi(this.kh,this.yh)}Ph(){return this.kh}Rh(){return this.yh}Dh(){return this.yh-this.kh}Ni(){return this.yh===this.kh||Number.isNaN(this.yh)||Number.isNaN(this.kh)}ts(t){return null===t?this:new yi(ki(Math.min,this.Ph(),t.Ph(),-1/0),ki(Math.max,this.Rh(),t.Rh(),1/0))}Oh(t){if(!P(t))return;if(0===this.yh-this.kh)return;const i=.5*(this.yh+this.kh);let n=this.yh-i,s=this.kh-i;n*=t,s*=t,this.yh=i+n,this.kh=i+s}Bh(t){P(t)&&(this.yh+=t,this.kh+=t)}Vh(){return{minValue:this.kh,maxValue:this.yh}}static Ah(t){return null===t?null:new yi(t.minValue,t.maxValue)}}class Ci{constructor(t,i){this.zh=t,this.Eh=i||null}Ih(){return this.zh}Lh(){return this.Eh}Vh(){return null===this.zh?null:{priceRange:this.zh.Vh(),margins:this.Eh||void 0}}static Ah(t){return null===t?null:new Ci(yi.Ah(t.priceRange),t.margins)}}class Ti extends ni{constructor(t,i){super(t),this.Nh=i}yr(){const t=this.Sr;t.yt=!1;const i=this.Nh.W();if(!this.Ls.yt()||!i.lineVisible)return;const n=this.Nh.Fh();null!==n&&(t.yt=!0,t.st=n,t.O=i.color,t.et=i.lineWidth,t.Nt=i.lineStyle,t.gr=this.Nh.W().id)}}class Pi extends J{constructor(t,i){super(),this.jr=t,this.Nh=i}Ei(t,i,n){t.yt=!1,i.yt=!1;const s=this.Nh.W(),e=s.axisLabelVisible,r=""!==s.title,h=this.jr;if(!e||!h.yt())return;const l=this.Nh.Fh();if(null===l)return;r&&(i.Kt=s.title,i.yt=!0),i.Bt=h.$t().Vt(l/h.Dt().At()),t.Kt=this.Wh(s.price),t.yt=!0;const a=y(s.axisLabelColor||s.color);n.t=a.t;const o=s.axisLabelTextColor||a.i;t.O=o,i.O=o,n.ki=l}Wh(t){const i=this.jr.Ct();return null===i?"":this.jr.Dt().Fi(t,i.Ot)}}class Ri{constructor(t,i){this.jr=t,this.cn=i,this.jh=new Ti(t,this),this.ur=new Pi(t,this),this.Hh=new ti(this.ur,t,t.$t())}$h(t){T(this.cn,t),this.bt(),this.jr.$t().Uh()}W(){return this.cn}qh(){return this.jh}Yh(){return this.Hh}Zh(){return this.ur}bt(){this.jh.bt(),this.ur.bt()}Fh(){const t=this.jr,i=t.Dt();if(t.$t().St().Ni()||i.Ni())return null;const n=t.Ct();return null===n?null:i.Rt(this.cn.price,n.Ot)}}class Di extends st{constructor(t){super(),this.$i=t}$t(){return this.$i}}const Oi={Bar:(t,i,n,s)=>{var e;const r=i.upColor,h=i.downColor,l=f(t(n,s)),a=v(l.Ot[0])<=v(l.Ot[3]);return{ce:null!==(e=l.O)&&void 0!==e?e:a?r:h}},Candlestick:(t,i,n,s)=>{var e,r,h;const l=i.upColor,a=i.downColor,o=i.borderUpColor,_=i.borderDownColor,u=i.wickUpColor,c=i.wickDownColor,d=f(t(n,s)),p=v(d.Ot[0])<=v(d.Ot[3]);return{ce:null!==(e=d.O)&&void 0!==e?e:p?l:a,Ne:null!==(r=d.Bt)&&void 0!==r?r:p?o:_,Le:null!==(h=d.Xh)&&void 0!==h?h:p?u:c}},Custom:(t,i,n,s)=>{var e;return{ce:null!==(e=f(t(n,s)).O)&&void 0!==e?e:i.color}},Area:(t,i,n,s)=>{var e,r,h,l;const a=f(t(n,s));return{ce:null!==(e=a.lt)&&void 0!==e?e:i.lineColor,lt:null!==(r=a.lt)&&void 0!==r?r:i.lineColor,Ps:null!==(h=a.Ps)&&void 0!==h?h:i.topColor,Rs:null!==(l=a.Rs)&&void 0!==l?l:i.bottomColor}},Baseline:(t,i,n,s)=>{var e,r,h,l,a,o;const _=f(t(n,s));return{ce:_.Ot[3]>=i.baseValue.price?i.topLineColor:i.bottomLineColor,Re:null!==(e=_.Re)&&void 0!==e?e:i.topLineColor,De:null!==(r=_.De)&&void 0!==r?r:i.bottomLineColor,ke:null!==(h=_.ke)&&void 0!==h?h:i.topFillColor1,ye:null!==(l=_.ye)&&void 0!==l?l:i.topFillColor2,Ce:null!==(a=_.Ce)&&void 0!==a?a:i.bottomFillColor1,Te:null!==(o=_.Te)&&void 0!==o?o:i.bottomFillColor2}},Line:(t,i,n,s)=>{var e,r;const h=f(t(n,s));return{ce:null!==(e=h.O)&&void 0!==e?e:i.color,lt:null!==(r=h.O)&&void 0!==r?r:i.color}},Histogram:(t,i,n,s)=>{var e;return{ce:null!==(e=f(t(n,s)).O)&&void 0!==e?e:i.color}}};class Bi{constructor(t){this.Kh=(t,i)=>void 0!==i?i.Ot:this.jr.zn().Gh(t),this.jr=t,this.Jh=Oi[t.Qh()]}$s(t,i){return this.Jh(this.Kh,this.jr.W(),t,i)}}var Vi;!function(t){t[t.NearestLeft=-1]="NearestLeft",t[t.None=0]="None",t[t.NearestRight=1]="NearestRight"}(Vi||(Vi={}));const Ai=30;class zi{constructor(){this.tl=[],this.il=new Map,this.nl=new Map}sl(){return this.Ks()>0?this.tl[this.tl.length-1]:null}el(){return this.Ks()>0?this.rl(0):null}An(){return this.Ks()>0?this.rl(this.tl.length-1):null}Ks(){return this.tl.length}Ni(){return 0===this.Ks()}Kr(t){return null!==this.hl(t,0)}Gh(t){return this.ll(t)}ll(t,i=0){const n=this.hl(t,i);return null===n?null:Object.assign(Object.assign({},this.al(n)),{ee:this.rl(n)})}ne(){return this.tl}ol(t,i,n){if(this.Ni())return null;let s=null;for(const e of n){s=Ei(s,this._l(t,i,e))}return s}J(t){this.nl.clear(),this.il.clear(),this.tl=t}rl(t){return this.tl[t].ee}al(t){return this.tl[t]}hl(t,i){const n=this.ul(t);if(null===n&&0!==i)switch(i){case-1:return this.cl(t);case 1:return this.dl(t);default:throw new TypeError("Unknown search mode")}return n}cl(t){let i=this.fl(t);return i>0&&(i-=1),i!==this.tl.length&&this.rl(i)t.eet.ee>i))}pl(t,i,n){let s=null;for(let e=t;es.bl&&(s.bl=t)))}return s}_l(t,i,n){if(this.Ni())return null;let s=null;const e=f(this.el()),r=f(this.An()),h=Math.max(t,e),l=Math.min(i,r),a=Math.ceil(h/Ai)*Ai,o=Math.max(a,Math.floor(l/Ai)*Ai);{const t=this.fl(h),e=this.vl(Math.min(l,a,i));s=Ei(s,this.pl(t,e,n))}let _=this.il.get(n);void 0===_&&(_=new Map,this.il.set(n,_));for(let t=Math.max(a+1,h);tnew Li(t)));return this.Cl={Ml:e,xl:r},r}Qi(){var t,i,n,s;const e=null!==(n=null===(i=(t=this.Ol).timeAxisViews)||void 0===i?void 0:i.call(t))&&void 0!==n?n:[];if((null===(s=this.Tl)||void 0===s?void 0:s.Ml)===e)return this.Tl.xl;const r=this.jr.$t().St(),h=e.map((t=>new Fi(t,r)));return this.Tl={Ml:e,xl:h},h}Rn(){var t,i,n,s;const e=null!==(n=null===(i=(t=this.Ol).priceAxisViews)||void 0===i?void 0:i.call(t))&&void 0!==n?n:[];if((null===(s=this.Pl)||void 0===s?void 0:s.Ml)===e)return this.Pl.xl;const r=this.jr.Dt(),h=e.map((t=>new Wi(t,r)));return this.Pl={Ml:e,xl:h},h}Vl(){var t,i,n,s;const e=null!==(n=null===(i=(t=this.Ol).priceAxisPaneViews)||void 0===i?void 0:i.call(t))&&void 0!==n?n:[];if((null===(s=this.Rl)||void 0===s?void 0:s.Ml)===e)return this.Rl.xl;const r=e.map((t=>new Li(t)));return this.Rl={Ml:e,xl:r},r}Al(){var t,i,n,s;const e=null!==(n=null===(i=(t=this.Ol).timeAxisPaneViews)||void 0===i?void 0:i.call(t))&&void 0!==n?n:[];if((null===(s=this.Dl)||void 0===s?void 0:s.Ml)===e)return this.Dl.xl;const r=e.map((t=>new Li(t)));return this.Dl={Ml:e,xl:r},r}zl(t,i){var n,s,e;return null!==(e=null===(s=(n=this.Ol).autoscaleInfo)||void 0===s?void 0:s.call(n,t,i))&&void 0!==e?e:null}wr(t,i){var n,s,e;return null!==(e=null===(s=(n=this.Ol).hitTest)||void 0===s?void 0:s.call(n,t,i))&&void 0!==e?e:null}}function Hi(t,i,n,s){t.forEach((t=>{i(t).forEach((t=>{t.Sl()===n&&s.push(t)}))}))}function $i(t){return t.Pn()}function Ui(t){return t.Vl()}function qi(t){return t.Al()}class Yi extends Di{constructor(t,i,n,s,e){super(t),this.Et=new zi,this.jh=new xi(this),this.El=[],this.Il=new si(this),this.Ll=null,this.Nl=null,this.Fl=[],this.Wl=[],this.jl=null,this.Hl=[],this.cn=i,this.$l=n;const r=new Si(this);this.rn=[r],this.Hh=new ti(r,this,t),"Area"!==n&&"Line"!==n&&"Baseline"!==n||(this.Ll=new ai(this)),this.Ul(),this.ql(e)}S(){null!==this.jl&&clearTimeout(this.jl)}mh(t){return this.cn.priceLineColor||t}Zr(t){const i={Xr:!0},n=this.Dt();if(this.$t().St().Ni()||n.Ni()||this.Et.Ni())return i;const s=this.$t().St().Xs(),e=this.Ct();if(null===s||null===e)return i;let r,h;if(t){const t=this.Et.sl();if(null===t)return i;r=t,h=t.ee}else{const t=this.Et.ll(s.ui(),-1);if(null===t)return i;if(r=this.Et.Gh(t.ee),null===r)return i;h=t.ee}const l=r.Ot[3],a=this.Us().$s(h,{Ot:r}),o=n.Rt(l,e.Ot);return{Xr:!1,_t:l,Kt:n.Fi(l,e.Ot),xh:n.Yl(l),Sh:n.Zl(l,e.Ot),O:a.ce,ki:o,ee:h}}Us(){return null!==this.Nl||(this.Nl=new Bi(this)),this.Nl}W(){return this.cn}$h(t){const i=t.priceScaleId;void 0!==i&&i!==this.cn.priceScaleId&&this.$t().Xl(this,i),T(this.cn,t),void 0!==t.priceFormat&&(this.Ul(),this.$t().Kl()),this.$t().Gl(this),this.$t().Jl(),this.wn.bt("options")}J(t,i){this.Et.J(t),this.Ql(),this.wn.bt("data"),this.dn.bt("data"),null!==this.Ll&&(i&&i.ta?this.Ll.$r():0===t.length&&this.Ll.Hr());const n=this.$t().dr(this);this.$t().ia(n),this.$t().Gl(this),this.$t().Jl(),this.$t().Uh()}na(t){this.Fl=t,this.Ql();const i=this.$t().dr(this);this.dn.bt("data"),this.$t().ia(i),this.$t().Gl(this),this.$t().Jl(),this.$t().Uh()}sa(){return this.Fl}dh(){return this.Wl}ea(t){const i=new Ri(this,t);return this.El.push(i),this.$t().Gl(this),i}ra(t){const i=this.El.indexOf(t);-1!==i&&this.El.splice(i,1),this.$t().Gl(this)}Qh(){return this.$l}Ct(){const t=this.ha();return null===t?null:{Ot:t.Ot[3],la:t.ot}}ha(){const t=this.$t().St().Xs();if(null===t)return null;const i=t.Bs();return this.Et.ll(i,1)}zn(){return this.Et}ph(t){const i=this.Et.Gh(t);return null===i?null:"Bar"===this.$l||"Candlestick"===this.$l||"Custom"===this.$l?{ge:i.Ot[0],Me:i.Ot[1],xe:i.Ot[2],Se:i.Ot[3]}:i.Ot[3]}aa(t){const i=[];Hi(this.Hl,$i,"top",i);const n=this.Ll;return null!==n&&n.yt()?(null===this.jl&&n.qr()&&(this.jl=setTimeout((()=>{this.jl=null,this.$t().oa()}),0)),n.Ur(),i.unshift(n),i):i}Pn(){const t=[];this._a()||t.push(this.Il),t.push(this.wn,this.jh,this.dn);const i=this.El.map((t=>t.qh()));return t.push(...i),Hi(this.Hl,$i,"normal",t),t}ua(){return this.ca($i,"bottom")}da(t){return this.ca(Ui,t)}fa(t){return this.ca(qi,t)}va(t,i){return this.Hl.map((n=>n.wr(t,i))).filter((t=>null!==t))}Ji(t){return[this.Hh,...this.El.map((t=>t.Yh()))]}Rn(t,i){if(i!==this.Yi&&!this._a())return[];const n=[...this.rn];for(const t of this.El)n.push(t.Zh());return this.Hl.forEach((t=>{n.push(...t.Rn())})),n}Qi(){const t=[];return this.Hl.forEach((i=>{t.push(...i.Qi())})),t}zl(t,i){if(void 0!==this.cn.autoscaleInfoProvider){const n=this.cn.autoscaleInfoProvider((()=>{const n=this.pa(t,i);return null===n?null:n.Vh()}));return Ci.Ah(n)}return this.pa(t,i)}ma(){return this.cn.priceFormat.minMove}ba(){return this.wa}On(){var t;this.wn.bt(),this.dn.bt();for(const t of this.rn)t.bt();for(const t of this.El)t.bt();this.jh.bt(),this.Il.bt(),null===(t=this.Ll)||void 0===t||t.bt(),this.Hl.forEach((t=>t.On()))}Dt(){return f(super.Dt())}kt(t){if(!(("Line"===this.$l||"Area"===this.$l||"Baseline"===this.$l)&&this.cn.crosshairMarkerVisible))return null;const i=this.Et.Gh(t);if(null===i)return null;return{_t:i.Ot[3],ht:this.ga(),Bt:this.Ma(),Pt:this.xa(),Tt:this.Sa(t)}}bh(){return this.cn.title}yt(){return this.cn.visible}ka(t){this.Hl.push(new ji(t,this))}ya(t){this.Hl=this.Hl.filter((i=>i.Bl()!==t))}Ca(){if(this.wn instanceof qt!=!1)return t=>this.wn.We(t)}Ta(){if(this.wn instanceof qt!=!1)return t=>this.wn.je(t)}_a(){return!ht(this.Dt().Pa())}pa(t,i){if(!R(t)||!R(i)||this.Et.Ni())return null;const n="Line"===this.$l||"Area"===this.$l||"Baseline"===this.$l||"Histogram"===this.$l?[3]:[2,1],s=this.Et.ol(t,i,n);let e=null!==s?new yi(s.ml,s.bl):null;if("Histogram"===this.Qh()){const t=this.cn.base,i=new yi(t,t);e=null!==e?e.ts(i):i}let r=this.dn.uh();return this.Hl.forEach((n=>{const s=n.zl(t,i);if(null==s?void 0:s.priceRange){const t=new yi(s.priceRange.minValue,s.priceRange.maxValue);e=null!==e?e.ts(t):t}var h,l,a,o;(null==s?void 0:s.margins)&&(h=r,l=s.margins,r={above:Math.max(null!==(a=null==h?void 0:h.above)&&void 0!==a?a:0,l.above),below:Math.max(null!==(o=null==h?void 0:h.below)&&void 0!==o?o:0,l.below)})})),new Ci(e,r)}ga(){switch(this.$l){case"Line":case"Area":case"Baseline":return this.cn.crosshairMarkerRadius}return 0}Ma(){switch(this.$l){case"Line":case"Area":case"Baseline":{const t=this.cn.crosshairMarkerBorderColor;if(0!==t.length)return t}}return null}xa(){switch(this.$l){case"Line":case"Area":case"Baseline":return this.cn.crosshairMarkerBorderWidth}return 0}Sa(t){switch(this.$l){case"Line":case"Area":case"Baseline":{const t=this.cn.crosshairMarkerBackgroundColor;if(0!==t.length)return t}}return this.Us().$s(t).ce}Ul(){switch(this.cn.priceFormat.type){case"custom":this.wa={format:this.cn.priceFormat.formatter};break;case"volume":this.wa=new ct(this.cn.priceFormat.precision);break;case"percent":this.wa=new ut(this.cn.priceFormat.precision);break;default:{const t=Math.pow(10,this.cn.priceFormat.precision);this.wa=new _t(t,this.cn.priceFormat.minMove*t)}}null!==this.Yi&&this.Yi.Ra()}Ql(){const t=this.$t().St();if(!t.Da()||this.Et.Ni())return void(this.Wl=[]);const i=f(this.Et.el());this.Wl=this.Fl.map(((n,s)=>{const e=f(t.Oa(n.time,!0)),r=et instanceof Yi)).reduce(((t,s)=>{if(n.vr(s)||!s.yt())return t;const e=s.Dt(),r=s.zn();if(e.Ni()||!r.Kr(i))return t;const h=r.Gh(i);if(null===h)return t;const l=v(s.Ct());return t.concat([e.Rt(h.Ot[3],l.Ot)])}),[]);if(0===l.length)return s;l.sort(((t,i)=>Math.abs(t-h)-Math.abs(i-h)));const a=l[0];return s=e.pn(a,r),s}}class Xi extends N{constructor(){super(...arguments),this.Et=null}J(t){this.Et=t}K({context:t,bitmapSize:i,horizontalPixelRatio:n,verticalPixelRatio:s}){if(null===this.Et)return;const e=Math.max(1,Math.floor(n));t.lineWidth=e,function(t,i){t.save(),t.lineWidth%2&&t.translate(.5,.5),i(),t.restore()}(t,(()=>{const r=f(this.Et);if(r.Aa){t.strokeStyle=r.za,_(t,r.Ea),t.beginPath();for(const s of r.Ia){const r=Math.round(s.La*n);t.moveTo(r,-e),t.lineTo(r,i.height+e)}t.stroke()}if(r.Na){t.strokeStyle=r.Fa,_(t,r.Wa),t.beginPath();for(const n of r.ja){const r=Math.round(n.La*s);t.moveTo(-e,r),t.lineTo(i.width+e,r)}t.stroke()}}))}}class Ki{constructor(t){this.Wt=new Xi,this.ft=!0,this.tn=t}bt(){this.ft=!0}gt(){if(this.ft){const t=this.tn.$t().W().grid,i={Na:t.horzLines.visible,Aa:t.vertLines.visible,Fa:t.horzLines.color,za:t.vertLines.color,Wa:t.horzLines.style,Ea:t.vertLines.style,ja:this.tn.vn().Ha(),Ia:(this.tn.$t().St().Ha()||[]).map((t=>({La:t.coord})))};this.Wt.J(i),this.ft=!1}return this.Wt}}class Gi{constructor(t){this.wn=new Ki(t)}qh(){return this.wn}}const Ji={$a:4,Ua:1e-4};function Qi(t,i){const n=100*(t-i)/i;return i<0?-n:n}function tn(t,i){const n=Qi(t.Ph(),i),s=Qi(t.Rh(),i);return new yi(n,s)}function nn(t,i){const n=100*(t-i)/i+100;return i<0?-n:n}function sn(t,i){const n=nn(t.Ph(),i),s=nn(t.Rh(),i);return new yi(n,s)}function en(t,i){const n=Math.abs(t);if(n<1e-15)return 0;const s=Math.log10(n+i.Ua)+i.$a;return t<0?-s:s}function rn(t,i){const n=Math.abs(t);if(n<1e-15)return 0;const s=Math.pow(10,n-i.$a)-i.Ua;return t<0?-s:s}function hn(t,i){if(null===t)return null;const n=en(t.Ph(),i),s=en(t.Rh(),i);return new yi(n,s)}function ln(t,i){if(null===t)return null;const n=rn(t.Ph(),i),s=rn(t.Rh(),i);return new yi(n,s)}function an(t){if(null===t)return Ji;const i=Math.abs(t.Rh()-t.Ph());if(i>=1||i<1e-15)return Ji;const n=Math.ceil(Math.abs(Math.log10(i))),s=Ji.$a+n;return{$a:s,Ua:1/Math.pow(10,s)}}class on{constructor(t,i){if(this.qa=t,this.Ya=i,function(t){if(t<0)return!1;for(let i=t;i>1;i/=10)if(i%10!=0)return!1;return!0}(this.qa))this.Za=[2,2.5,2];else{this.Za=[];for(let t=this.qa;1!==t;){if(t%2==0)this.Za.push(2),t/=2;else{if(t%5!=0)throw new Error("unexpected base");this.Za.push(2,2.5),t/=5}if(this.Za.length>100)throw new Error("something wrong with base")}}}Xa(t,i,n){const s=0===this.qa?0:1/this.qa;let e=Math.pow(10,Math.max(0,Math.ceil(Math.log10(t-i)))),r=0,h=this.Ya[0];for(;;){const t=Mt(e,s,1e-14)&&e>s+1e-14,i=Mt(e,n*h,1e-14),l=Mt(e,1,1e-14);if(!(t&&i&&l))break;e/=h,h=this.Ya[++r%this.Ya.length]}if(e<=s+1e-14&&(e=s),e=Math.max(1,e),this.Za.length>0&&(l=e,a=1,o=1e-14,Math.abs(l-a)s+1e-14;)e/=h,h=this.Za[++r%this.Za.length];var l,a,o;return e}}class _n{constructor(t,i,n,s){this.Ka=[],this.Ii=t,this.qa=i,this.Ga=n,this.Ja=s}Xa(t,i){if(t=o?1:-1;let d=null,f=0;for(let n=a-u;n>o;n-=_){const s=this.Ja(n,i,!0);null!==d&&Math.abs(s-d)l||(ff(t.Xi())-f(i.Xi())))}var cn;!function(t){t[t.Normal=0]="Normal",t[t.Logarithmic=1]="Logarithmic",t[t.Percentage=2]="Percentage",t[t.IndexedTo100=3]="IndexedTo100"}(cn||(cn={}));const dn=new ut,fn=new _t(100,1);class vn{constructor(t,i,n,s){this.ho=0,this.lo=null,this.zh=null,this.ao=null,this.oo={_o:!1,uo:null},this.co=0,this.do=0,this.fo=new C,this.vo=new C,this.po=[],this.mo=null,this.bo=null,this.wo=null,this.Mo=null,this.wa=fn,this.xo=an(null),this.So=t,this.cn=i,this.ko=n,this.yo=s,this.Co=new _n(this,100,this.To.bind(this),this.Po.bind(this))}Pa(){return this.So}W(){return this.cn}$h(t){if(T(this.cn,t),this.Ra(),void 0!==t.mode&&this.Ro({Cr:t.mode}),void 0!==t.scaleMargins){const i=d(t.scaleMargins.top),n=d(t.scaleMargins.bottom);if(i<0||i>1)throw new Error(`Invalid top margin - expect value between 0 and 1, given=${i}`);if(n<0||n>1)throw new Error(`Invalid bottom margin - expect value between 0 and 1, given=${n}`);if(i+n>1)throw new Error(`Invalid margins - sum of margins must be less than 1, given=${i+n}`);this.Do(),this.bo=null}}Oo(){return this.cn.autoScale}ro(){return 1===this.cn.mode}Mh(){return 2===this.cn.mode}Bo(){return 3===this.cn.mode}Cr(){return{Wn:this.cn.autoScale,Vo:this.cn.invertScale,Cr:this.cn.mode}}Ro(t){const i=this.Cr();let n=null;void 0!==t.Wn&&(this.cn.autoScale=t.Wn),void 0!==t.Cr&&(this.cn.mode=t.Cr,2!==t.Cr&&3!==t.Cr||(this.cn.autoScale=!0),this.oo._o=!1),1===i.Cr&&t.Cr!==i.Cr&&(!function(t,i){if(null===t)return!1;const n=rn(t.Ph(),i),s=rn(t.Rh(),i);return isFinite(n)&&isFinite(s)}(this.zh,this.xo)?this.cn.autoScale=!0:(n=ln(this.zh,this.xo),null!==n&&this.Ao(n))),1===t.Cr&&t.Cr!==i.Cr&&(n=hn(this.zh,this.xo),null!==n&&this.Ao(n));const s=i.Cr!==this.cn.mode;s&&(2===i.Cr||this.Mh())&&this.Ra(),s&&(3===i.Cr||this.Bo())&&this.Ra(),void 0!==t.Vo&&i.Vo!==t.Vo&&(this.cn.invertScale=t.Vo,this.zo()),this.vo.m(i,this.Cr())}Eo(){return this.vo}P(){return this.ko.fontSize}At(){return this.ho}Io(t){this.ho!==t&&(this.ho=t,this.Do(),this.bo=null)}Lo(){if(this.lo)return this.lo;const t=this.At()-this.No()-this.Fo();return this.lo=t,t}Ih(){return this.Wo(),this.zh}Ao(t,i){const n=this.zh;(i||null===n&&null!==t||null!==n&&!n.Ch(t))&&(this.bo=null,this.zh=t)}Ni(){return this.Wo(),0===this.ho||!this.zh||this.zh.Ni()}jo(t){return this.Vo()?t:this.At()-1-t}Rt(t,i){return this.Mh()?t=Qi(t,i):this.Bo()&&(t=nn(t,i)),this.Po(t,i)}te(t,i,n){this.Wo();const s=this.Fo(),e=f(this.Ih()),r=e.Ph(),h=e.Rh(),l=this.Lo()-1,a=this.Vo(),o=l/(h-r),_=void 0===n?0:n.from,u=void 0===n?t.length:n.to,c=this.Ho();for(let n=_;nt.On()))}Ra(){this.bo=null;const t=this.e_();let i=100;null!==t&&(i=Math.round(1/t.ma())),this.wa=fn,this.Mh()?(this.wa=dn,i=100):this.Bo()?(this.wa=new _t(100,1),i=100):null!==t&&(this.wa=t.ba()),this.Co=new _n(this,i,this.To.bind(this),this.Po.bind(this)),this.Co.io()}Yo(){this.mo=null}e_(){return this.po[0]||null}No(){return this.Vo()?this.cn.scaleMargins.bottom*this.At()+this.do:this.cn.scaleMargins.top*this.At()+this.co}Fo(){return this.Vo()?this.cn.scaleMargins.top*this.At()+this.co:this.cn.scaleMargins.bottom*this.At()+this.do}Wo(){this.oo._o||(this.oo._o=!0,this.l_())}Do(){this.lo=null}Po(t,i){if(this.Wo(),this.Ni())return 0;t=this.ro()&&t?en(t,this.xo):t;const n=f(this.Ih()),s=this.Fo()+(this.Lo()-1)*(t-n.Ph())/n.Dh();return this.jo(s)}To(t,i){if(this.Wo(),this.Ni())return 0;const n=this.jo(t),s=f(this.Ih()),e=s.Ph()+s.Dh()*((n-this.Fo())/(this.Lo()-1));return this.ro()?rn(e,this.xo):e}zo(){this.bo=null,this.Co.io()}l_(){const t=this.oo.uo;if(null===t)return;let i=null;const n=this.r_();let s=0,e=0;for(const r of n){if(!r.yt())continue;const n=r.Ct();if(null===n)continue;const h=r.zl(t.Bs(),t.ui());let l=h&&h.Ih();if(null!==l){switch(this.cn.mode){case 1:l=hn(l,this.xo);break;case 2:l=tn(l,n.Ot);break;case 3:l=sn(l,n.Ot)}if(i=null===i?l:i.ts(f(l)),null!==h){const t=h.Lh();null!==t&&(s=Math.max(s,t.above),e=Math.max(e,t.below))}}}if(s===this.co&&e===this.do||(this.co=s,this.do=e,this.bo=null,this.Do()),null!==i){if(i.Ph()===i.Rh()){const t=this.e_(),n=5*(null===t||this.Mh()||this.Bo()?1:t.ma());this.ro()&&(i=ln(i,this.xo)),i=new yi(i.Ph()-n,i.Rh()+n),this.ro()&&(i=hn(i,this.xo))}if(this.ro()){const t=ln(i,this.xo),n=an(t);if(r=n,h=this.xo,r.$a!==h.$a||r.Ua!==h.Ua){const s=null!==this.ao?ln(this.ao,this.xo):null;this.xo=n,i=hn(t,n),null!==s&&(this.ao=hn(s,n))}}this.Ao(i)}else null===this.zh&&(this.Ao(new yi(-.5,.5)),this.xo=an(null));var r,h;this.oo._o=!0}Ho(){return this.Mh()?Qi:this.Bo()?nn:this.ro()?t=>en(t,this.xo):null}a_(t,i,n){return void 0===i?(void 0===n&&(n=this.ba()),n.format(t)):i(t)}Wh(t,i){return this.a_(t,this.yo.priceFormatter,i)}s_(t,i){return this.a_(t,this.yo.percentageFormatter,i)}}class pn{constructor(t,i){this.po=[],this.o_=new Map,this.ho=0,this.__=0,this.u_=1e3,this.mo=null,this.c_=new C,this.yl=t,this.$i=i,this.d_=new Gi(this);const n=i.W();this.f_=this.v_("left",n.leftPriceScale),this.p_=this.v_("right",n.rightPriceScale),this.f_.Eo().l(this.m_.bind(this,this.f_),this),this.p_.Eo().l(this.m_.bind(this,this.p_),this),this.b_(n)}b_(t){if(t.leftPriceScale&&this.f_.$h(t.leftPriceScale),t.rightPriceScale&&this.p_.$h(t.rightPriceScale),t.localization&&(this.f_.Ra(),this.p_.Ra()),t.overlayPriceScales){const i=Array.from(this.o_.values());for(const n of i){const i=f(n[0].Dt());i.$h(t.overlayPriceScales),t.localization&&i.Ra()}}}w_(t){switch(t){case"left":return this.f_;case"right":return this.p_}return this.o_.has(t)?d(this.o_.get(t))[0].Dt():null}S(){this.$t().g_().p(this),this.f_.Eo().p(this),this.p_.Eo().p(this),this.po.forEach((t=>{t.S&&t.S()})),this.c_.m()}M_(){return this.u_}x_(t){this.u_=t}$t(){return this.$i}Hi(){return this.__}At(){return this.ho}S_(t){this.__=t,this.k_()}Io(t){this.ho=t,this.f_.Io(t),this.p_.Io(t),this.po.forEach((i=>{if(this.vr(i)){const n=i.Dt();null!==n&&n.Io(t)}})),this.k_()}Va(){return this.po}vr(t){const i=t.Dt();return null===i||this.f_!==i&&this.p_!==i}qo(t,i,n){const s=void 0!==n?n:this.C_().y_+1;this.T_(t,i,s)}Zo(t){const i=this.po.indexOf(t);c(-1!==i,"removeDataSource: invalid data source"),this.po.splice(i,1);const n=f(t.Dt()).Pa();if(this.o_.has(n)){const i=d(this.o_.get(n)),s=i.indexOf(t);-1!==s&&(i.splice(s,1),0===i.length&&this.o_.delete(n))}const s=t.Dt();s&&s.Va().indexOf(t)>=0&&s.Zo(t),null!==s&&(s.Yo(),this.P_(s)),this.mo=null}mr(t){return t===this.f_?"left":t===this.p_?"right":"overlay"}R_(){return this.f_}D_(){return this.p_}O_(t,i){t.Go(i)}B_(t,i){t.Jo(i),this.k_()}V_(t){t.Qo()}A_(t,i){t.t_(i)}z_(t,i){t.i_(i),this.k_()}E_(t){t.n_()}k_(){this.po.forEach((t=>{t.On()}))}vn(){let t=null;return this.$i.W().rightPriceScale.visible&&0!==this.p_.Va().length?t=this.p_:this.$i.W().leftPriceScale.visible&&0!==this.f_.Va().length?t=this.f_:0!==this.po.length&&(t=this.po[0].Dt()),null===t&&(t=this.p_),t}pr(){let t=null;return this.$i.W().rightPriceScale.visible?t=this.p_:this.$i.W().leftPriceScale.visible&&(t=this.f_),t}P_(t){null!==t&&t.Oo()&&this.I_(t)}L_(t){const i=this.yl.Xs();t.Ro({Wn:!0}),null!==i&&t.h_(i),this.k_()}N_(){this.I_(this.f_),this.I_(this.p_)}F_(){this.P_(this.f_),this.P_(this.p_),this.po.forEach((t=>{this.vr(t)&&this.P_(t.Dt())})),this.k_(),this.$i.Uh()}Uo(){return null===this.mo&&(this.mo=un(this.po)),this.mo}W_(){return this.c_}j_(){return this.d_}I_(t){const i=t.r_();if(i&&i.length>0&&!this.yl.Ni()){const i=this.yl.Xs();null!==i&&t.h_(i)}t.On()}C_(){const t=this.Uo();if(0===t.length)return{H_:0,y_:0};let i=0,n=0;for(let s=0;sn&&(n=e))}return{H_:i,y_:n}}T_(t,i,n){let s=this.w_(i);if(null===s&&(s=this.v_(i,this.$i.W().overlayPriceScales)),this.po.push(t),!ht(i)){const n=this.o_.get(i)||[];n.push(t),this.o_.set(i,n)}s.qo(t),t.Gi(s),t.Ki(n),this.P_(s),this.mo=null}m_(t,i,n){i.Cr!==n.Cr&&this.I_(t)}v_(t,i){const n=Object.assign({visible:!0,autoScale:!0},B(i)),s=new vn(t,n,this.$i.W().layout,this.$i.W().localization);return s.Io(this.At()),s}}class mn{constructor(t,i,n=50){this.Ke=0,this.Ge=1,this.Je=1,this.tr=new Map,this.Qe=new Map,this.U_=t,this.q_=i,this.ir=n}Y_(t){const i=t.time,n=this.q_.cacheKey(i),s=this.tr.get(n);if(void 0!==s)return s.Z_;if(this.Ke===this.ir){const t=this.Qe.get(this.Je);this.Qe.delete(this.Je),this.tr.delete(d(t)),this.Je++,this.Ke--}const e=this.U_(t);return this.tr.set(n,{Z_:e,rr:this.Ge}),this.Qe.set(this.Ge,n),this.Ke++,this.Ge++,e}}class bn{constructor(t,i){c(t<=i,"right should be >= left"),this.X_=t,this.K_=i}Bs(){return this.X_}ui(){return this.K_}G_(){return this.K_-this.X_+1}Kr(t){return this.X_<=t&&t<=this.K_}Ch(t){return this.X_===t.Bs()&&this.K_===t.ui()}}function wn(t,i){return null===t||null===i?t===i:t.Ch(i)}class gn{constructor(){this.J_=new Map,this.tr=null,this.Q_=!1}tu(t){this.Q_=t,this.tr=null}iu(t,i){this.nu(i),this.tr=null;for(let n=i;n{t<=n[0].index?i.push(s):n.splice(Rt(n,t,(i=>i.indexi-t))){if(!this.J_.get(n))continue;const s=i;i=[];const e=s.length;let r=0;const h=d(this.J_.get(n)),l=h.length;let a=1/0,o=-1/0;for(let n=0;n=t&&_-o>=t)i.push(l),o=_;else if(this.Q_)return s}for(;ri.weight?t:i}class Sn{constructor(t,i,n,s){this.__=0,this._u=null,this.uu=[],this.Mo=null,this.wo=null,this.cu=new gn,this.du=new Map,this.fu=Mn.ou(),this.vu=!0,this.pu=new C,this.mu=new C,this.bu=new C,this.wu=null,this.gu=null,this.Mu=[],this.cn=i,this.yo=n,this.xu=i.rightOffset,this.Su=i.barSpacing,this.$i=t,this.q_=s,this.ku(),this.cu.tu(i.uniformDistribution)}W(){return this.cn}yu(t){T(this.yo,t),this.Cu(),this.ku()}$h(t,i){var n;T(this.cn,t),this.cn.fixLeftEdge&&this.Tu(),this.cn.fixRightEdge&&this.Pu(),void 0!==t.barSpacing&&this.$i.Gn(t.barSpacing),void 0!==t.rightOffset&&this.$i.Jn(t.rightOffset),void 0!==t.minBarSpacing&&this.$i.Gn(null!==(n=t.barSpacing)&&void 0!==n?n:this.Su),this.Cu(),this.ku(),this.bu.m()}mn(t){var i,n;return null!==(n=null===(i=this.uu[t])||void 0===i?void 0:i.time)&&void 0!==n?n:null}Ui(t){var i;return null!==(i=this.uu[t])&&void 0!==i?i:null}Oa(t,i){if(this.uu.length<1)return null;if(this.q_.key(t)>this.q_.key(this.uu[this.uu.length-1].time))return i?this.uu.length-1:null;const n=Rt(this.uu,this.q_.key(t),((t,i)=>this.q_.key(t.time)0}Xs(){return this.Ru(),this.fu.lu()}Du(){return this.Ru(),this.fu.au()}Ou(){const t=this.Xs();if(null===t)return null;const i={from:t.Bs(),to:t.ui()};return this.Bu(i)}Bu(t){const i=Math.round(t.from),n=Math.round(t.to),s=f(this.Vu()),e=f(this.Au());return{from:f(this.Ui(Math.max(s,i))),to:f(this.Ui(Math.min(e,n)))}}zu(t){return{from:f(this.Oa(t.from,!0)),to:f(this.Oa(t.to,!0))}}Hi(){return this.__}S_(t){if(!isFinite(t)||t<=0)return;if(this.__===t)return;const i=this.Du(),n=this.__;if(this.__=t,this.vu=!0,this.cn.lockVisibleTimeRangeOnResize&&0!==n){const i=this.Su*t/n;this.Su=i}if(this.cn.fixLeftEdge&&null!==i&&i.Bs()<=0){const i=n-t;this.xu-=Math.round(i/this.Su)+1,this.vu=!0}this.Eu(),this.Iu()}zt(t){if(this.Ni()||!R(t))return 0;const i=this.Lu()+this.xu-t;return this.__-(i+.5)*this.Su-1}Qs(t,i){const n=this.Lu(),s=void 0===i?0:i.from,e=void 0===i?t.length:i.to;for(let i=s;ii/2&&!o?n.needAlignCoordinate=!1:n.needAlignCoordinate=_&&t.index<=l||u&&t.index>=a,c++}return this.Mu.length=c,this.gu=this.Mu,this.Mu}qu(){this.vu=!0,this.Gn(this.cn.barSpacing),this.Jn(this.cn.rightOffset)}Yu(t){this.vu=!0,this._u=t,this.Iu(),this.Tu()}Zu(t,i){const n=this.Fu(t),s=this.le(),e=s+i*(s/10);this.Gn(e),this.cn.rightBarStaysOnScroll||this.Jn(this.Hu()+(n-this.Fu(t)))}Go(t){this.Mo&&this.n_(),null===this.wo&&null===this.wu&&(this.Ni()||(this.wo=t,this.Xu()))}Jo(t){if(null===this.wu)return;const i=gt(this.__-t,0,this.__),n=gt(this.__-f(this.wo),0,this.__);0!==i&&0!==n&&this.Gn(this.wu.le*i/n)}Qo(){null!==this.wo&&(this.wo=null,this.Ku())}t_(t){null===this.Mo&&null===this.wu&&(this.Ni()||(this.Mo=t,this.Xu()))}i_(t){if(null===this.Mo)return;const i=(this.Mo-t)/this.le();this.xu=f(this.wu).Hu+i,this.vu=!0,this.Iu()}n_(){null!==this.Mo&&(this.Mo=null,this.Ku())}Gu(){this.Ju(this.cn.rightOffset)}Ju(t,i=400){if(!isFinite(t))throw new RangeError("offset is required and must be finite number");if(!isFinite(i)||i<=0)throw new RangeError("animationDuration (optional) must be finite positive number");const n=this.xu,s=performance.now();this.$i.Zn({Qu:t=>(t-s)/i>=1,tc:e=>{const r=(e-s)/i;return r>=1?t:n+(t-n)*r}})}bt(t,i){this.vu=!0,this.uu=t,this.cu.iu(t,i),this.Iu()}nc(){return this.pu}sc(){return this.mu}ec(){return this.bu}Lu(){return this._u||0}rc(t){const i=t.G_();this.ju(this.__/i),this.xu=t.ui()-this.Lu(),this.Iu(),this.vu=!0,this.$i.Wu(),this.$i.Uh()}hc(){const t=this.Vu(),i=this.Au();null!==t&&null!==i&&this.rc(new bn(t,i+this.cn.rightOffset))}lc(t){const i=new bn(t.from,t.to);this.rc(i)}qi(t){return void 0!==this.yo.timeFormatter?this.yo.timeFormatter(t.originalTime):this.q_.formatHorzItem(t.time)}$u(){const{handleScroll:t,handleScale:i}=this.$i.W();return!(t.horzTouchDrag||t.mouseWheel||t.pressedMouseMove||t.vertTouchDrag||i.axisDoubleClickReset.time||i.axisPressedMouseMove.time||i.mouseWheel||i.pinch)}Vu(){return 0===this.uu.length?null:0}Au(){return 0===this.uu.length?null:this.uu.length-1}ac(t){return(this.__-1-t)/this.Su}Fu(t){const i=this.ac(t),n=this.Lu()+this.xu-i;return Math.round(1e6*n)/1e6}ju(t){const i=this.Su;this.Su=t,this.Eu(),i!==this.Su&&(this.vu=!0,this.oc())}Ru(){if(!this.vu)return;if(this.vu=!1,this.Ni())return void this._c(Mn.ou());const t=this.Lu(),i=this.__/this.Su,n=this.xu+t,s=new bn(n-i+1,n);this._c(new Mn(s))}Eu(){const t=this.uc();if(this.Sut&&(this.Su=t,this.vu=!0)}}uc(){return this.cn.fixLeftEdge&&this.cn.fixRightEdge&&0!==this.uu.length?this.__/this.uu.length:this.cn.minBarSpacing}Iu(){const t=this.cc();null!==t&&this.xui&&(this.xu=i,this.vu=!0)}cc(){const t=this.Vu(),i=this._u;if(null===t||null===i)return null;return t-i-1+(this.cn.fixLeftEdge?this.__/this.Su:Math.min(2,this.uu.length))}dc(){return this.cn.fixRightEdge?0:this.__/this.Su-Math.min(2,this.uu.length)}Xu(){this.wu={le:this.le(),Hu:this.Hu()}}Ku(){this.wu=null}Uu(t){let i=this.du.get(t.weight);return void 0===i&&(i=new mn((t=>this.fc(t)),this.q_),this.du.set(t.weight,i)),i.Y_(t)}fc(t){return this.q_.formatTickmark(t,this.yo)}_c(t){const i=this.fu;this.fu=t,wn(i.lu(),this.fu.lu())||this.pu.m(),wn(i.au(),this.fu.au())||this.mu.m(),this.oc()}oc(){this.gu=null}Cu(){this.oc(),this.du.clear()}ku(){this.q_.updateFormatter(this.yo)}Tu(){if(!this.cn.fixLeftEdge)return;const t=this.Vu();if(null===t)return;const i=this.Xs();if(null===i)return;const n=i.Bs()-t;if(n<0){const t=this.xu-n-1;this.Jn(t)}this.Eu()}Pu(){this.Iu(),this.Eu()}}class kn{X(t,i,n){t.useMediaCoordinateSpace((t=>this.K(t,i,n)))}gl(t,i,n){t.useMediaCoordinateSpace((t=>this.vc(t,i,n)))}vc(t,i,n){}}class yn extends kn{constructor(t){super(),this.mc=new Map,this.Et=t}K(t){}vc(t){if(!this.Et.yt)return;const{context:i,mediaSize:n}=t;let s=0;for(const t of this.Et.bc){if(0===t.Kt.length)continue;i.font=t.R;const e=this.wc(i,t.Kt);e>n.width?t.Zu=n.width/e:t.Zu=1,s+=t.gc*t.Zu}let e=0;switch(this.Et.Mc){case"top":e=0;break;case"center":e=Math.max((n.height-s)/2,0);break;case"bottom":e=Math.max(n.height-s,0)}i.fillStyle=this.Et.O;for(const t of this.Et.bc){i.save();let s=0;switch(this.Et.xc){case"left":i.textAlign="left",s=t.gc/2;break;case"center":i.textAlign="center",s=n.width/2;break;case"right":i.textAlign="right",s=n.width-1-t.gc/2}i.translate(s,e),i.textBaseline="top",i.font=t.R,i.scale(t.Zu,t.Zu),i.fillText(t.Kt,0,t.Sc),i.restore(),e+=t.gc*t.Zu}}wc(t,i){const n=this.kc(t.font);let s=n.get(i);return void 0===s&&(s=t.measureText(i).width,n.set(i,s)),s}kc(t){let i=this.mc.get(t);return void 0===i&&(i=new Map,this.mc.set(t,i)),i}}class Cn{constructor(t){this.ft=!0,this.Ft={yt:!1,O:"",bc:[],Mc:"center",xc:"center"},this.Wt=new yn(this.Ft),this.jt=t}bt(){this.ft=!0}gt(){return this.ft&&(this.Mt(),this.ft=!1),this.Wt}Mt(){const t=this.jt.W(),i=this.Ft;i.yt=t.visible,i.yt&&(i.O=t.color,i.xc=t.horzAlign,i.Mc=t.vertAlign,i.bc=[{Kt:t.text,R:E(t.fontSize,t.fontFamily,t.fontStyle),gc:1.2*t.fontSize,Sc:0,Zu:0}])}}class Tn extends st{constructor(t,i){super(),this.cn=i,this.wn=new Cn(this)}Rn(){return[]}Pn(){return[this.wn]}W(){return this.cn}On(){this.wn.bt()}}var Pn,Rn,Dn,On,Bn;!function(t){t[t.OnTouchEnd=0]="OnTouchEnd",t[t.OnNextTap=1]="OnNextTap"}(Pn||(Pn={}));class Vn{constructor(t,i,n){this.yc=[],this.Cc=[],this.__=0,this.Tc=null,this.Pc=new C,this.Rc=new C,this.Dc=null,this.Oc=t,this.cn=i,this.q_=n,this.Bc=new I(this),this.yl=new Sn(this,i.timeScale,this.cn.localization,n),this.vt=new rt(this,i.crosshair),this.Vc=new Zi(i.crosshair),this.Ac=new Tn(this,i.watermark),this.zc(),this.yc[0].x_(2e3),this.Ec=this.Ic(0),this.Lc=this.Ic(1)}Kl(){this.Nc(lt.es())}Uh(){this.Nc(lt.ss())}oa(){this.Nc(new lt(1))}Gl(t){const i=this.Fc(t);this.Nc(i)}Wc(){return this.Tc}jc(t){const i=this.Tc;this.Tc=t,null!==i&&this.Gl(i.Hc),null!==t&&this.Gl(t.Hc)}W(){return this.cn}$h(t){T(this.cn,t),this.yc.forEach((i=>i.b_(t))),void 0!==t.timeScale&&this.yl.$h(t.timeScale),void 0!==t.localization&&this.yl.yu(t.localization),(t.leftPriceScale||t.rightPriceScale)&&this.Pc.m(),this.Ec=this.Ic(0),this.Lc=this.Ic(1),this.Kl()}$c(t,i){if("left"===t)return void this.$h({leftPriceScale:i});if("right"===t)return void this.$h({rightPriceScale:i});const n=this.Uc(t);null!==n&&(n.Dt.$h(i),this.Pc.m())}Uc(t){for(const i of this.yc){const n=i.w_(t);if(null!==n)return{Ht:i,Dt:n}}return null}St(){return this.yl}qc(){return this.yc}Yc(){return this.Ac}Zc(){return this.vt}Xc(){return this.Rc}Kc(t,i){t.Io(i),this.Wu()}S_(t){this.__=t,this.yl.S_(this.__),this.yc.forEach((i=>i.S_(t))),this.Wu()}zc(t){const i=new pn(this.yl,this);void 0!==t?this.yc.splice(t,0,i):this.yc.push(i);const n=void 0===t?this.yc.length-1:t,s=lt.es();return s.Nn(n,{Fn:0,Wn:!0}),this.Nc(s),i}O_(t,i,n){t.O_(i,n)}B_(t,i,n){t.B_(i,n),this.Jl(),this.Nc(this.Gc(t,2))}V_(t,i){t.V_(i),this.Nc(this.Gc(t,2))}A_(t,i,n){i.Oo()||t.A_(i,n)}z_(t,i,n){i.Oo()||(t.z_(i,n),this.Jl(),this.Nc(this.Gc(t,2)))}E_(t,i){i.Oo()||(t.E_(i),this.Nc(this.Gc(t,2)))}L_(t,i){t.L_(i),this.Nc(this.Gc(t,2))}Jc(t){this.yl.Go(t)}Qc(t,i){const n=this.St();if(n.Ni()||0===i)return;const s=n.Hi();t=Math.max(1,Math.min(t,s)),n.Zu(t,i),this.Wu()}td(t){this.nd(0),this.sd(t),this.ed()}rd(t){this.yl.Jo(t),this.Wu()}hd(){this.yl.Qo(),this.Uh()}nd(t){this.yl.t_(t)}sd(t){this.yl.i_(t),this.Wu()}ed(){this.yl.n_(),this.Uh()}wt(){return this.Cc}ld(t,i,n,s,e){this.vt.gn(t,i);let r=NaN,h=this.yl.Nu(t);const l=this.yl.Xs();null!==l&&(h=Math.min(Math.max(l.Bs(),h),l.ui()));const a=s.vn(),o=a.Ct();null!==o&&(r=a.pn(i,o)),r=this.Vc.Ba(r,h,s),this.vt.kn(h,r,s),this.oa(),e||this.Rc.m(this.vt.xt(),{x:t,y:i},n)}ad(t,i,n){const s=n.vn(),e=s.Ct(),r=s.Rt(t,f(e)),h=this.yl.Oa(i,!0),l=this.yl.zt(f(h));this.ld(l,r,null,n,!0)}od(t){this.Zc().Cn(),this.oa(),t||this.Rc.m(null,null,null)}Jl(){const t=this.vt.Ht();if(null!==t){const i=this.vt.xn(),n=this.vt.Sn();this.ld(i,n,null,t)}this.vt.On()}_d(t,i,n){const s=this.yl.mn(0);void 0!==i&&void 0!==n&&this.yl.bt(i,n);const e=this.yl.mn(0),r=this.yl.Lu(),h=this.yl.Xs();if(null!==h&&null!==s&&null!==e){const i=h.Kr(r),l=this.q_.key(s)>this.q_.key(e),a=null!==t&&t>r&&!l,o=this.yl.W().allowShiftVisibleRangeOnWhitespaceReplacement,_=i&&(!(void 0===n)||o)&&this.yl.W().shiftVisibleRangeOnNewBar;if(a&&!_){const i=t-r;this.yl.Jn(this.yl.Hu()-i)}}this.yl.Yu(t)}ia(t){null!==t&&t.F_()}dr(t){const i=this.yc.find((i=>i.Uo().includes(t)));return void 0===i?null:i}Wu(){this.Ac.On(),this.yc.forEach((t=>t.F_())),this.Jl()}S(){this.yc.forEach((t=>t.S())),this.yc.length=0,this.cn.localization.priceFormatter=void 0,this.cn.localization.percentageFormatter=void 0,this.cn.localization.timeFormatter=void 0}ud(){return this.Bc}br(){return this.Bc.W()}g_(){return this.Pc}dd(t,i,n){const s=this.yc[0],e=this.fd(i,t,s,n);return this.Cc.push(e),1===this.Cc.length?this.Kl():this.Uh(),e}vd(t){const i=this.dr(t),n=this.Cc.indexOf(t);c(-1!==n,"Series not found"),this.Cc.splice(n,1),f(i).Zo(t),t.S&&t.S()}Xl(t,i){const n=f(this.dr(t));n.Zo(t);const s=this.Uc(i);if(null===s){const s=t.Xi();n.qo(t,i,s)}else{const e=s.Ht===n?t.Xi():void 0;s.Ht.qo(t,i,e)}}hc(){const t=lt.ss();t.$n(),this.Nc(t)}pd(t){const i=lt.ss();i.Yn(t),this.Nc(i)}Kn(){const t=lt.ss();t.Kn(),this.Nc(t)}Gn(t){const i=lt.ss();i.Gn(t),this.Nc(i)}Jn(t){const i=lt.ss();i.Jn(t),this.Nc(i)}Zn(t){const i=lt.ss();i.Zn(t),this.Nc(i)}Un(){const t=lt.ss();t.Un(),this.Nc(t)}md(){return this.cn.rightPriceScale.visible?"right":"left"}bd(){return this.Lc}q(){return this.Ec}Vt(t){const i=this.Lc,n=this.Ec;if(i===n)return i;if(t=Math.max(0,Math.min(100,Math.round(100*t))),null===this.Dc||this.Dc.Ps!==n||this.Dc.Rs!==i)this.Dc={Ps:n,Rs:i,wd:new Map};else{const i=this.Dc.wd.get(t);if(void 0!==i)return i}const s=function(t,i,n){const[s,e,r,h]=S(t),[l,a,o,_]=S(i),u=[m(s+n*(l-s)),m(e+n*(a-e)),m(r+n*(o-r)),b(h+n*(_-h))];return`rgba(${u[0]}, ${u[1]}, ${u[2]}, ${u[3]})`}(n,i,t/100);return this.Dc.wd.set(t,s),s}Gc(t,i){const n=new lt(i);if(null!==t){const s=this.yc.indexOf(t);n.Nn(s,{Fn:i})}return n}Fc(t,i){return void 0===i&&(i=2),this.Gc(this.dr(t),i)}Nc(t){this.Oc&&this.Oc(t),this.yc.forEach((t=>t.j_().qh().bt()))}fd(t,i,n,s){const e=new Yi(this,t,i,n,s),r=void 0!==t.priceScaleId?t.priceScaleId:this.md();return n.qo(e,r),ht(r)||e.$h(t),e}Ic(t){const i=this.cn.layout;return"gradient"===i.background.type?0===t?i.background.topColor:i.background.bottomColor:i.background.color}}function An(t){return!P(t)&&!D(t)}function zn(t){return P(t)}!function(t){t[t.Disabled=0]="Disabled",t[t.Continuous=1]="Continuous",t[t.OnDataUpdate=2]="OnDataUpdate"}(Rn||(Rn={})),function(t){t[t.LastBar=0]="LastBar",t[t.LastVisible=1]="LastVisible"}(Dn||(Dn={})),function(t){t.Solid="solid",t.VerticalGradient="gradient"}(On||(On={})),function(t){t[t.Year=0]="Year",t[t.Month=1]="Month",t[t.DayOfMonth=2]="DayOfMonth",t[t.Time=3]="Time",t[t.TimeWithSeconds=4]="TimeWithSeconds"}(Bn||(Bn={}));const En=t=>t.getUTCFullYear();function In(t,i,n){return i.replace(/yyyy/g,(t=>ot(En(t),4))(t)).replace(/yy/g,(t=>ot(En(t)%100,2))(t)).replace(/MMMM/g,((t,i)=>new Date(t.getUTCFullYear(),t.getUTCMonth(),1).toLocaleString(i,{month:"long"}))(t,n)).replace(/MMM/g,((t,i)=>new Date(t.getUTCFullYear(),t.getUTCMonth(),1).toLocaleString(i,{month:"short"}))(t,n)).replace(/MM/g,(t=>ot((t=>t.getUTCMonth()+1)(t),2))(t)).replace(/dd/g,(t=>ot((t=>t.getUTCDate())(t),2))(t))}class Ln{constructor(t="yyyy-MM-dd",i="default"){this.gd=t,this.Md=i}Y_(t){return In(t,this.gd,this.Md)}}class Nn{constructor(t){this.xd=t||"%h:%m:%s"}Y_(t){return this.xd.replace("%h",ot(t.getUTCHours(),2)).replace("%m",ot(t.getUTCMinutes(),2)).replace("%s",ot(t.getUTCSeconds(),2))}}const Fn={Sd:"yyyy-MM-dd",kd:"%h:%m:%s",yd:" ",Cd:"default"};class Wn{constructor(t={}){const i=Object.assign(Object.assign({},Fn),t);this.Td=new Ln(i.Sd,i.Cd),this.Pd=new Nn(i.kd),this.Rd=i.yd}Y_(t){return`${this.Td.Y_(t)}${this.Rd}${this.Pd.Y_(t)}`}}function jn(t){return 60*t*60*1e3}function Hn(t){return 60*t*1e3}const $n=[{Dd:(Un=1,1e3*Un),Od:10},{Dd:Hn(1),Od:20},{Dd:Hn(5),Od:21},{Dd:Hn(30),Od:22},{Dd:jn(1),Od:30},{Dd:jn(3),Od:31},{Dd:jn(6),Od:32},{Dd:jn(12),Od:33}];var Un;function qn(t,i){if(t.getUTCFullYear()!==i.getUTCFullYear())return 70;if(t.getUTCMonth()!==i.getUTCMonth())return 60;if(t.getUTCDate()!==i.getUTCDate())return 50;for(let n=$n.length-1;n>=0;--n)if(Math.floor(i.getTime()/$n[n].Dd)!==Math.floor(t.getTime()/$n[n].Dd))return $n[n].Od;return 0}function Yn(t){let i=t;if(D(t)&&(i=Xn(t)),!An(i))throw new Error("time must be of type BusinessDay");const n=new Date(Date.UTC(i.year,i.month-1,i.day,0,0,0,0));return{Bd:Math.round(n.getTime()/1e3),Vd:i}}function Zn(t){if(!zn(t))throw new Error("time must be of type isUTCTimestamp");return{Bd:t}}function Xn(t){const i=new Date(t);if(isNaN(i.getTime()))throw new Error(`Invalid date string=${t}, expected format=yyyy-mm-dd`);return{day:i.getUTCDate(),month:i.getUTCMonth()+1,year:i.getUTCFullYear()}}function Kn(t){D(t.time)&&(t.time=Xn(t.time))}class Gn{options(){return this.cn}setOptions(t){this.cn=t,this.updateFormatter(t.localization)}preprocessData(t){Array.isArray(t)?function(t){t.forEach(Kn)}(t):Kn(t)}createConverterToInternalObj(t){return f(function(t){return 0===t.length?null:An(t[0].time)||D(t[0].time)?Yn:Zn}(t))}key(t){return"object"==typeof t&&"Bd"in t?t.Bd:this.key(this.convertHorzItemToInternal(t))}cacheKey(t){const i=t;return void 0===i.Vd?new Date(1e3*i.Bd).getTime():new Date(Date.UTC(i.Vd.year,i.Vd.month-1,i.Vd.day)).getTime()}convertHorzItemToInternal(t){return zn(i=t)?Zn(i):An(i)?Yn(i):Yn(Xn(i));var i}updateFormatter(t){if(!this.cn)return;const i=t.dateFormat;this.cn.timeScale.timeVisible?this.Ad=new Wn({Sd:i,kd:this.cn.timeScale.secondsVisible?"%h:%m:%s":"%h:%m",yd:" ",Cd:t.locale}):this.Ad=new Ln(i,t.locale)}formatHorzItem(t){const i=t;return this.Ad.Y_(new Date(1e3*i.Bd))}formatTickmark(t,i){const n=function(t,i,n){switch(t){case 0:case 10:return i?n?4:3:2;case 20:case 21:case 22:case 30:case 31:case 32:case 33:return i?3:2;case 50:return 2;case 60:return 1;case 70:return 0}}(t.weight,this.cn.timeScale.timeVisible,this.cn.timeScale.secondsVisible),s=this.cn.timeScale;if(void 0!==s.tickMarkFormatter){const e=s.tickMarkFormatter(t.originalTime,n,i.locale);if(null!==e)return e}return function(t,i,n){const s={};switch(i){case 0:s.year="numeric";break;case 1:s.month="short";break;case 2:s.day="numeric";break;case 3:s.hour12=!1,s.hour="2-digit",s.minute="2-digit";break;case 4:s.hour12=!1,s.hour="2-digit",s.minute="2-digit",s.second="2-digit"}const e=void 0===t.Vd?new Date(1e3*t.Bd):new Date(Date.UTC(t.Vd.year,t.Vd.month-1,t.Vd.day));return new Date(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate(),e.getUTCHours(),e.getUTCMinutes(),e.getUTCSeconds(),e.getUTCMilliseconds()).toLocaleString(n,s)}(t.time,n,i.locale)}maxTickMarkWeight(t){let i=t.reduce(xn,t[0]).weight;return i>30&&i<50&&(i=30),i}fillWeightsForPoints(t,i){!function(t,i=0){if(0===t.length)return;let n=0===i?null:t[i-1].time.Bd,s=null!==n?new Date(1e3*n):null,e=0;for(let r=i;r1){const i=Math.ceil(e/(t.length-1)),n=new Date(1e3*(t[0].time.Bd-i));t[0].timeWeight=qn(new Date(1e3*t[0].time.Bd),n)}}(t,i)}static zd(t){return T({localization:{dateFormat:"dd MMM 'yy"}},null!=t?t:{})}}function Jn(t){var i=t.width,n=t.height;if(i<0)throw new Error("Negative width is not allowed for Size");if(n<0)throw new Error("Negative height is not allowed for Size");return{width:i,height:n}}function Qn(t,i){return t.width===i.width&&t.height===i.height}var ts=function(){function t(t){var i=this;this._resolutionListener=function(){return i._onResolutionChanged()},this._resolutionMediaQueryList=null,this._observers=[],this._window=t,this._installResolutionListener()}return t.prototype.dispose=function(){this._uninstallResolutionListener(),this._window=null},Object.defineProperty(t.prototype,"value",{get:function(){return this._window.devicePixelRatio},enumerable:!1,configurable:!0}),t.prototype.subscribe=function(t){var i=this,n={next:t};return this._observers.push(n),{unsubscribe:function(){i._observers=i._observers.filter((function(t){return t!==n}))}}},t.prototype._installResolutionListener=function(){if(null!==this._resolutionMediaQueryList)throw new Error("Resolution listener is already installed");var t=this._window.devicePixelRatio;this._resolutionMediaQueryList=this._window.matchMedia("all and (resolution: ".concat(t,"dppx)")),this._resolutionMediaQueryList.addListener(this._resolutionListener)},t.prototype._uninstallResolutionListener=function(){null!==this._resolutionMediaQueryList&&(this._resolutionMediaQueryList.removeListener(this._resolutionListener),this._resolutionMediaQueryList=null)},t.prototype._reinstallResolutionListener=function(){this._uninstallResolutionListener(),this._installResolutionListener()},t.prototype._onResolutionChanged=function(){var t=this;this._observers.forEach((function(i){return i.next(t._window.devicePixelRatio)})),this._reinstallResolutionListener()},t}();var is=function(){function t(t,i,n){var s;this._canvasElement=null,this._bitmapSizeChangedListeners=[],this._suggestedBitmapSize=null,this._suggestedBitmapSizeChangedListeners=[],this._devicePixelRatioObservable=null,this._canvasElementResizeObserver=null,this._canvasElement=t,this._canvasElementClientSize=Jn({width:this._canvasElement.clientWidth,height:this._canvasElement.clientHeight}),this._transformBitmapSize=null!=i?i:function(t){return t},this._allowResizeObserver=null===(s=null==n?void 0:n.allowResizeObserver)||void 0===s||s,this._chooseAndInitObserver()}return t.prototype.dispose=function(){var t,i;if(null===this._canvasElement)throw new Error("Object is disposed");null===(t=this._canvasElementResizeObserver)||void 0===t||t.disconnect(),this._canvasElementResizeObserver=null,null===(i=this._devicePixelRatioObservable)||void 0===i||i.dispose(),this._devicePixelRatioObservable=null,this._suggestedBitmapSizeChangedListeners.length=0,this._bitmapSizeChangedListeners.length=0,this._canvasElement=null},Object.defineProperty(t.prototype,"canvasElement",{get:function(){if(null===this._canvasElement)throw new Error("Object is disposed");return this._canvasElement},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"canvasElementClientSize",{get:function(){return this._canvasElementClientSize},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"bitmapSize",{get:function(){return Jn({width:this.canvasElement.width,height:this.canvasElement.height})},enumerable:!1,configurable:!0}),t.prototype.resizeCanvasElement=function(t){this._canvasElementClientSize=Jn(t),this.canvasElement.style.width="".concat(this._canvasElementClientSize.width,"px"),this.canvasElement.style.height="".concat(this._canvasElementClientSize.height,"px"),this._invalidateBitmapSize()},t.prototype.subscribeBitmapSizeChanged=function(t){this._bitmapSizeChangedListeners.push(t)},t.prototype.unsubscribeBitmapSizeChanged=function(t){this._bitmapSizeChangedListeners=this._bitmapSizeChangedListeners.filter((function(i){return i!==t}))},Object.defineProperty(t.prototype,"suggestedBitmapSize",{get:function(){return this._suggestedBitmapSize},enumerable:!1,configurable:!0}),t.prototype.subscribeSuggestedBitmapSizeChanged=function(t){this._suggestedBitmapSizeChangedListeners.push(t)},t.prototype.unsubscribeSuggestedBitmapSizeChanged=function(t){this._suggestedBitmapSizeChangedListeners=this._suggestedBitmapSizeChangedListeners.filter((function(i){return i!==t}))},t.prototype.applySuggestedBitmapSize=function(){if(null!==this._suggestedBitmapSize){var t=this._suggestedBitmapSize;this._suggestedBitmapSize=null,this._resizeBitmap(t),this._emitSuggestedBitmapSizeChanged(t,this._suggestedBitmapSize)}},t.prototype._resizeBitmap=function(t){var i=this.bitmapSize;Qn(i,t)||(this.canvasElement.width=t.width,this.canvasElement.height=t.height,this._emitBitmapSizeChanged(i,t))},t.prototype._emitBitmapSizeChanged=function(t,i){var n=this;this._bitmapSizeChangedListeners.forEach((function(s){return s.call(n,t,i)}))},t.prototype._suggestNewBitmapSize=function(t){var i=this._suggestedBitmapSize,n=Jn(this._transformBitmapSize(t,this._canvasElementClientSize)),s=Qn(this.bitmapSize,n)?null:n;null===i&&null===s||null!==i&&null!==s&&Qn(i,s)||(this._suggestedBitmapSize=s,this._emitSuggestedBitmapSizeChanged(i,s))},t.prototype._emitSuggestedBitmapSizeChanged=function(t,i){var n=this;this._suggestedBitmapSizeChangedListeners.forEach((function(s){return s.call(n,t,i)}))},t.prototype._chooseAndInitObserver=function(){var t=this;this._allowResizeObserver?new Promise((function(t){var i=new ResizeObserver((function(n){t(n.every((function(t){return"devicePixelContentBoxSize"in t}))),i.disconnect()}));i.observe(document.body,{box:"device-pixel-content-box"})})).catch((function(){return!1})).then((function(i){return i?t._initResizeObserver():t._initDevicePixelRatioObservable()})):this._initDevicePixelRatioObservable()},t.prototype._initDevicePixelRatioObservable=function(){var t=this;if(null!==this._canvasElement){var i=ns(this._canvasElement);if(null===i)throw new Error("No window is associated with the canvas");this._devicePixelRatioObservable=function(t){return new ts(t)}(i),this._devicePixelRatioObservable.subscribe((function(){return t._invalidateBitmapSize()})),this._invalidateBitmapSize()}},t.prototype._invalidateBitmapSize=function(){var t,i;if(null!==this._canvasElement){var n=ns(this._canvasElement);if(null!==n){var s=null!==(i=null===(t=this._devicePixelRatioObservable)||void 0===t?void 0:t.value)&&void 0!==i?i:n.devicePixelRatio,e=this._canvasElement.getClientRects(),r=void 0!==e[0]?function(t,i){return Jn({width:Math.round(t.left*i+t.width*i)-Math.round(t.left*i),height:Math.round(t.top*i+t.height*i)-Math.round(t.top*i)})}(e[0],s):Jn({width:this._canvasElementClientSize.width*s,height:this._canvasElementClientSize.height*s});this._suggestNewBitmapSize(r)}}},t.prototype._initResizeObserver=function(){var t=this;null!==this._canvasElement&&(this._canvasElementResizeObserver=new ResizeObserver((function(i){var n=i.find((function(i){return i.target===t._canvasElement}));if(n&&n.devicePixelContentBoxSize&&n.devicePixelContentBoxSize[0]){var s=n.devicePixelContentBoxSize[0],e=Jn({width:s.inlineSize,height:s.blockSize});t._suggestNewBitmapSize(e)}})),this._canvasElementResizeObserver.observe(this._canvasElement,{box:"device-pixel-content-box"}))},t}();function ns(t){return t.ownerDocument.defaultView}var ss=function(){function t(t,i,n){if(0===i.width||0===i.height)throw new TypeError("Rendering target could only be created on a media with positive width and height");if(this._mediaSize=i,0===n.width||0===n.height)throw new TypeError("Rendering target could only be created using a bitmap with positive integer width and height");this._bitmapSize=n,this._context=t}return t.prototype.useMediaCoordinateSpace=function(t){try{return this._context.save(),this._context.setTransform(1,0,0,1,0,0),this._context.scale(this._horizontalPixelRatio,this._verticalPixelRatio),t({context:this._context,mediaSize:this._mediaSize})}finally{this._context.restore()}},t.prototype.useBitmapCoordinateSpace=function(t){try{return this._context.save(),this._context.setTransform(1,0,0,1,0,0),t({context:this._context,mediaSize:this._mediaSize,bitmapSize:this._bitmapSize,horizontalPixelRatio:this._horizontalPixelRatio,verticalPixelRatio:this._verticalPixelRatio})}finally{this._context.restore()}},Object.defineProperty(t.prototype,"_horizontalPixelRatio",{get:function(){return this._bitmapSize.width/this._mediaSize.width},enumerable:!1,configurable:!0}),Object.defineProperty(t.prototype,"_verticalPixelRatio",{get:function(){return this._bitmapSize.height/this._mediaSize.height},enumerable:!1,configurable:!0}),t}();function es(t,i){var n=t.canvasElementClientSize;if(0===n.width||0===n.height)return null;var s=t.bitmapSize;if(0===s.width||0===s.height)return null;var e=t.canvasElement.getContext("2d",i);return null===e?null:new ss(e,n,s)}const rs="undefined"!=typeof window;function hs(){return!!rs&&window.navigator.userAgent.toLowerCase().indexOf("firefox")>-1}function ls(){return!!rs&&/iPhone|iPad|iPod/.test(window.navigator.platform)}function as(t){return t+t%2}function os(t,i){return t.Ed-i.Ed}function _s(t,i,n){const s=(t.Ed-i.Ed)/(t.ot-i.ot);return Math.sign(s)*Math.min(Math.abs(s),n)}class us{constructor(t,i,n,s){this.Id=null,this.Ld=null,this.Nd=null,this.Fd=null,this.Wd=null,this.jd=0,this.Hd=0,this.$d=t,this.Ud=i,this.qd=n,this.rs=s}Yd(t,i){if(null!==this.Id){if(this.Id.ot===i)return void(this.Id.Ed=t);if(Math.abs(this.Id.Ed-t)50)return;let n=0;const s=_s(this.Id,this.Ld,this.Ud),e=os(this.Id,this.Ld),r=[s],h=[e];if(n+=e,null!==this.Nd){const t=_s(this.Ld,this.Nd,this.Ud);if(Math.sign(t)===Math.sign(s)){const i=os(this.Ld,this.Nd);if(r.push(t),h.push(i),n+=i,null!==this.Fd){const t=_s(this.Nd,this.Fd,this.Ud);if(Math.sign(t)===Math.sign(s)){const i=os(this.Nd,this.Fd);r.push(t),h.push(i),n+=i}}}}let l=0;for(let t=0;t160?"dark":"light"}sf(){return this.Qd.W().layout.attributionLogo}rf(){const t=new URL(location.href);return t.hostname?"&utm_source="+t.hostname+t.pathname:""}tf(){this.nf()&&(this.if(),this.en=this.sf(),this.en&&(this.Gd=this.ef(),this.Kd=document.createElement("style"),this.Kd.innerText="a#tv-attr-logo{--fill:#131722;--stroke:#fff;position:absolute;left:10px;bottom:10px;height:19px;width:35px;margin:0;padding:0;border:0;z-index:3;}a#tv-attr-logo[data-dark]{--fill:#D1D4DC;--stroke:#131722;}",this.Xd=document.createElement("a"),this.Xd.href=`https://www.tradingview.com/?utm_medium=lwc-link&utm_campaign=lwc-chart${this.rf()}`,this.Xd.title="Charting by TradingView",this.Xd.id="tv-attr-logo",this.Xd.target="_blank",this.Xd.innerHTML='',this.Xd.toggleAttribute("data-dark","dark"===this.Gd),this.Jd.appendChild(this.Kd),this.Jd.appendChild(this.Xd)))}}function ds(t,i){const n=f(t.ownerDocument).createElement("canvas");t.appendChild(n);const s=function(t,i){if("device-pixel-content-box"===i.type)return new is(t,i.transform,i.options);throw new Error("Unsupported binding target")}(n,{type:"device-pixel-content-box",options:{allowResizeObserver:!1},transform:(t,i)=>({width:Math.max(t.width,i.width),height:Math.max(t.height,i.height)})});return s.resizeCanvasElement(i),s}function fs(t){var i;t.width=1,t.height=1,null===(i=t.getContext("2d"))||void 0===i||i.clearRect(0,0,1,1)}function vs(t,i,n,s){t.gl&&t.gl(i,n,s)}function ps(t,i,n,s){t.X(i,n,s)}function ms(t,i,n,s){const e=t(n,s);for(const t of e){const n=t.gt();null!==n&&i(n)}}function bs(t){rs&&void 0!==window.chrome&&t.addEventListener("mousedown",(t=>{if(1===t.button)return t.preventDefault(),!1}))}class ws{constructor(t,i,n){this.hf=0,this.lf=null,this.af={nt:Number.NEGATIVE_INFINITY,st:Number.POSITIVE_INFINITY},this._f=0,this.uf=null,this.cf={nt:Number.NEGATIVE_INFINITY,st:Number.POSITIVE_INFINITY},this.df=null,this.ff=!1,this.vf=null,this.pf=null,this.mf=!1,this.bf=!1,this.wf=!1,this.gf=null,this.Mf=null,this.xf=null,this.Sf=null,this.kf=null,this.yf=null,this.Cf=null,this.Tf=0,this.Pf=!1,this.Rf=!1,this.Df=!1,this.Of=0,this.Bf=null,this.Vf=!ls(),this.Af=t=>{this.zf(t)},this.Ef=t=>{if(this.If(t)){const i=this.Lf(t);if(++this._f,this.uf&&this._f>1){const{Nf:n}=this.Ff(xs(t),this.cf);n<30&&!this.wf&&this.Wf(i,this.Hf.jf),this.$f()}}else{const i=this.Lf(t);if(++this.hf,this.lf&&this.hf>1){const{Nf:n}=this.Ff(xs(t),this.af);n<5&&!this.bf&&this.Uf(i,this.Hf.qf),this.Yf()}}},this.Zf=t,this.Hf=i,this.cn=n,this.Xf()}S(){null!==this.gf&&(this.gf(),this.gf=null),null!==this.Mf&&(this.Mf(),this.Mf=null),null!==this.Sf&&(this.Sf(),this.Sf=null),null!==this.kf&&(this.kf(),this.kf=null),null!==this.yf&&(this.yf(),this.yf=null),null!==this.xf&&(this.xf(),this.xf=null),this.Kf(),this.Yf()}Gf(t){this.Sf&&this.Sf();const i=this.Jf.bind(this);if(this.Sf=()=>{this.Zf.removeEventListener("mousemove",i)},this.Zf.addEventListener("mousemove",i),this.If(t))return;const n=this.Lf(t);this.Uf(n,this.Hf.Qf),this.Vf=!0}Yf(){null!==this.lf&&clearTimeout(this.lf),this.hf=0,this.lf=null,this.af={nt:Number.NEGATIVE_INFINITY,st:Number.POSITIVE_INFINITY}}$f(){null!==this.uf&&clearTimeout(this.uf),this._f=0,this.uf=null,this.cf={nt:Number.NEGATIVE_INFINITY,st:Number.POSITIVE_INFINITY}}Jf(t){if(this.Df||null!==this.pf)return;if(this.If(t))return;const i=this.Lf(t);this.Uf(i,this.Hf.tv),this.Vf=!0}iv(t){const i=ks(t.changedTouches,f(this.Bf));if(null===i)return;if(this.Of=Ss(t),null!==this.Cf)return;if(this.Rf)return;this.Pf=!0;const n=this.Ff(xs(i),f(this.pf)),{nv:s,sv:e,Nf:r}=n;if(this.mf||!(r<5)){if(!this.mf){const t=.5*s,i=e>=t&&!this.cn.ev(),n=t>e&&!this.cn.rv();i||n||(this.Rf=!0),this.mf=!0,this.wf=!0,this.Kf(),this.$f()}if(!this.Rf){const n=this.Lf(t,i);this.Wf(n,this.Hf.hv),Ms(t)}}}lv(t){if(0!==t.button)return;const i=this.Ff(xs(t),f(this.vf)),{Nf:n}=i;if(n>=5&&(this.bf=!0,this.Yf()),this.bf){const i=this.Lf(t);this.Uf(i,this.Hf.av)}}Ff(t,i){const n=Math.abs(i.nt-t.nt),s=Math.abs(i.st-t.st);return{nv:n,sv:s,Nf:n+s}}ov(t){let i=ks(t.changedTouches,f(this.Bf));if(null===i&&0===t.touches.length&&(i=t.changedTouches[0]),null===i)return;this.Bf=null,this.Of=Ss(t),this.Kf(),this.pf=null,this.yf&&(this.yf(),this.yf=null);const n=this.Lf(t,i);if(this.Wf(n,this.Hf._v),++this._f,this.uf&&this._f>1){const{Nf:t}=this.Ff(xs(i),this.cf);t<30&&!this.wf&&this.Wf(n,this.Hf.jf),this.$f()}else this.wf||(this.Wf(n,this.Hf.uv),this.Hf.uv&&Ms(t));0===this._f&&Ms(t),0===t.touches.length&&this.ff&&(this.ff=!1,Ms(t))}zf(t){if(0!==t.button)return;const i=this.Lf(t);if(this.vf=null,this.Df=!1,this.kf&&(this.kf(),this.kf=null),hs()){this.Zf.ownerDocument.documentElement.removeEventListener("mouseleave",this.Af)}if(!this.If(t))if(this.Uf(i,this.Hf.cv),++this.hf,this.lf&&this.hf>1){const{Nf:n}=this.Ff(xs(t),this.af);n<5&&!this.bf&&this.Uf(i,this.Hf.qf),this.Yf()}else this.bf||this.Uf(i,this.Hf.dv)}Kf(){null!==this.df&&(clearTimeout(this.df),this.df=null)}fv(t){if(null!==this.Bf)return;const i=t.changedTouches[0];this.Bf=i.identifier,this.Of=Ss(t);const n=this.Zf.ownerDocument.documentElement;this.wf=!1,this.mf=!1,this.Rf=!1,this.pf=xs(i),this.yf&&(this.yf(),this.yf=null);{const i=this.iv.bind(this),s=this.ov.bind(this);this.yf=()=>{n.removeEventListener("touchmove",i),n.removeEventListener("touchend",s)},n.addEventListener("touchmove",i,{passive:!1}),n.addEventListener("touchend",s,{passive:!1}),this.Kf(),this.df=setTimeout(this.vv.bind(this,t),240)}const s=this.Lf(t,i);this.Wf(s,this.Hf.pv),this.uf||(this._f=0,this.uf=setTimeout(this.$f.bind(this),500),this.cf=xs(i))}mv(t){if(0!==t.button)return;const i=this.Zf.ownerDocument.documentElement;hs()&&i.addEventListener("mouseleave",this.Af),this.bf=!1,this.vf=xs(t),this.kf&&(this.kf(),this.kf=null);{const t=this.lv.bind(this),n=this.zf.bind(this);this.kf=()=>{i.removeEventListener("mousemove",t),i.removeEventListener("mouseup",n)},i.addEventListener("mousemove",t),i.addEventListener("mouseup",n)}if(this.Df=!0,this.If(t))return;const n=this.Lf(t);this.Uf(n,this.Hf.bv),this.lf||(this.hf=0,this.lf=setTimeout(this.Yf.bind(this),500),this.af=xs(t))}Xf(){this.Zf.addEventListener("mouseenter",this.Gf.bind(this)),this.Zf.addEventListener("touchcancel",this.Kf.bind(this));{const t=this.Zf.ownerDocument,i=t=>{this.Hf.wv&&(t.composed&&this.Zf.contains(t.composedPath()[0])||t.target&&this.Zf.contains(t.target)||this.Hf.wv())};this.Mf=()=>{t.removeEventListener("touchstart",i)},this.gf=()=>{t.removeEventListener("mousedown",i)},t.addEventListener("mousedown",i),t.addEventListener("touchstart",i,{passive:!0})}ls()&&(this.xf=()=>{this.Zf.removeEventListener("dblclick",this.Ef)},this.Zf.addEventListener("dblclick",this.Ef)),this.Zf.addEventListener("mouseleave",this.gv.bind(this)),this.Zf.addEventListener("touchstart",this.fv.bind(this),{passive:!0}),bs(this.Zf),this.Zf.addEventListener("mousedown",this.mv.bind(this)),this.Mv(),this.Zf.addEventListener("touchmove",(()=>{}),{passive:!1})}Mv(){void 0===this.Hf.xv&&void 0===this.Hf.Sv&&void 0===this.Hf.kv||(this.Zf.addEventListener("touchstart",(t=>this.yv(t.touches)),{passive:!0}),this.Zf.addEventListener("touchmove",(t=>{if(2===t.touches.length&&null!==this.Cf&&void 0!==this.Hf.Sv){const i=gs(t.touches[0],t.touches[1])/this.Tf;this.Hf.Sv(this.Cf,i),Ms(t)}}),{passive:!1}),this.Zf.addEventListener("touchend",(t=>{this.yv(t.touches)})))}yv(t){1===t.length&&(this.Pf=!1),2!==t.length||this.Pf||this.ff?this.Cv():this.Tv(t)}Tv(t){const i=this.Zf.getBoundingClientRect()||{left:0,top:0};this.Cf={nt:(t[0].clientX-i.left+(t[1].clientX-i.left))/2,st:(t[0].clientY-i.top+(t[1].clientY-i.top))/2},this.Tf=gs(t[0],t[1]),void 0!==this.Hf.xv&&this.Hf.xv(),this.Kf()}Cv(){null!==this.Cf&&(this.Cf=null,void 0!==this.Hf.kv&&this.Hf.kv())}gv(t){if(this.Sf&&this.Sf(),this.If(t))return;if(!this.Vf)return;const i=this.Lf(t);this.Uf(i,this.Hf.Pv),this.Vf=!ls()}vv(t){const i=ks(t.touches,f(this.Bf));if(null===i)return;const n=this.Lf(t,i);this.Wf(n,this.Hf.Rv),this.wf=!0,this.ff=!0}If(t){return t.sourceCapabilities&&void 0!==t.sourceCapabilities.firesTouchEvents?t.sourceCapabilities.firesTouchEvents:Ss(t){"touchstart"!==t.type&&Ms(t)}}}}function gs(t,i){const n=t.clientX-i.clientX,s=t.clientY-i.clientY;return Math.sqrt(n*n+s*s)}function Ms(t){t.cancelable&&t.preventDefault()}function xs(t){return{nt:t.pageX,st:t.pageY}}function Ss(t){return t.timeStamp||performance.now()}function ks(t,i){for(let n=0;n{var s,e,r,h;return(null!==(e=null===(s=n.Dt())||void 0===s?void 0:s.Pa())&&void 0!==e?e:"")!==i?[]:null!==(h=null===(r=n.da)||void 0===r?void 0:r.call(n,t))&&void 0!==h?h:[]}}function Ps(t,i,n,s){if(!t.length)return;let e=0;const r=n/2,h=t[0].At(s,!0);let l=1===i?r-(t[0].Oi()-h/2):t[0].Oi()-h/2-r;l=Math.max(0,l);for(let r=1;ru-o:_n)&&l>0){const s=1===i?-1-r:r-n,h=Math.min(s,l);for(let n=e;n{this.jv||this.tn.$v().$t().Uh()},this.Uv=()=>{this.jv||this.tn.$v().$t().Uh()},this.tn=t,this.cn=i,this.ko=i.layout,this.Bc=n,this.qv="left"===s,this.Yv=Ts("normal",s),this.Zv=Ts("top",s),this.Xv=Ts("bottom",s),this.Kv=document.createElement("div"),this.Kv.style.height="100%",this.Kv.style.overflow="hidden",this.Kv.style.width="25px",this.Kv.style.left="0",this.Kv.style.position="relative",this.Gv=ds(this.Kv,Jn({width:16,height:16})),this.Gv.subscribeSuggestedBitmapSizeChanged(this.Hv);const e=this.Gv.canvasElement;e.style.position="absolute",e.style.zIndex="1",e.style.left="0",e.style.top="0",this.Jv=ds(this.Kv,Jn({width:16,height:16})),this.Jv.subscribeSuggestedBitmapSizeChanged(this.Uv);const r=this.Jv.canvasElement;r.style.position="absolute",r.style.zIndex="2",r.style.left="0",r.style.top="0";const h={bv:this.Qv.bind(this),pv:this.Qv.bind(this),av:this.tp.bind(this),hv:this.tp.bind(this),wv:this.ip.bind(this),cv:this.np.bind(this),_v:this.np.bind(this),qf:this.sp.bind(this),jf:this.sp.bind(this),Qf:this.ep.bind(this),Pv:this.rp.bind(this)};this.hp=new ws(this.Jv.canvasElement,h,{ev:()=>!this.cn.handleScroll.vertTouchDrag,rv:()=>!0})}S(){this.hp.S(),this.Jv.unsubscribeSuggestedBitmapSizeChanged(this.Uv),fs(this.Jv.canvasElement),this.Jv.dispose(),this.Gv.unsubscribeSuggestedBitmapSizeChanged(this.Hv),fs(this.Gv.canvasElement),this.Gv.dispose(),null!==this.Ii&&this.Ii.Ko().p(this),this.Ii=null}lp(){return this.Kv}P(){return this.ko.fontSize}ap(){const t=this.Bc.W();return this.Qr!==t.R&&(this.Fv.nr(),this.Qr=t.R),t}op(){if(null===this.Ii)return 0;let t=0;const i=this.ap(),n=f(this.Gv.canvasElement.getContext("2d"));n.save();const s=this.Ii.Ha();n.font=this._p(),s.length>0&&(t=Math.max(this.Fv.xi(n,s[0].so),this.Fv.xi(n,s[s.length-1].so)));const e=this.up();for(let i=e.length;i--;){const s=this.Fv.xi(n,e[i].Kt());s>t&&(t=s)}const r=this.Ii.Ct();if(null!==r&&null!==this.Lv&&(2!==(h=this.cn.crosshair).mode&&h.horzLine.visible&&h.horzLine.labelVisible)){const i=this.Ii.pn(1,r),s=this.Ii.pn(this.Lv.height-2,r);t=Math.max(t,this.Fv.xi(n,this.Ii.Fi(Math.floor(Math.min(i,s))+.11111111111111,r)),this.Fv.xi(n,this.Ii.Fi(Math.ceil(Math.max(i,s))-.11111111111111,r)))}var h;n.restore();const l=t||34;return as(Math.ceil(i.C+i.T+i.A+i.I+5+l))}cp(t){null!==this.Lv&&Qn(this.Lv,t)||(this.Lv=t,this.jv=!0,this.Gv.resizeCanvasElement(t),this.Jv.resizeCanvasElement(t),this.jv=!1,this.Kv.style.width=`${t.width}px`,this.Kv.style.height=`${t.height}px`)}dp(){return f(this.Lv).width}Gi(t){this.Ii!==t&&(null!==this.Ii&&this.Ii.Ko().p(this),this.Ii=t,t.Ko().l(this.fo.bind(this),this))}Dt(){return this.Ii}nr(){const t=this.tn.fp();this.tn.$v().$t().L_(t,f(this.Dt()))}vp(t){if(null===this.Lv)return;if(1!==t){this.pp(),this.Gv.applySuggestedBitmapSize();const t=es(this.Gv);null!==t&&(t.useBitmapCoordinateSpace((t=>{this.mp(t),this.ze(t)})),this.tn.bp(t,this.Xv),this.wp(t),this.tn.bp(t,this.Yv),this.gp(t))}this.Jv.applySuggestedBitmapSize();const i=es(this.Jv);null!==i&&(i.useBitmapCoordinateSpace((({context:t,bitmapSize:i})=>{t.clearRect(0,0,i.width,i.height)})),this.Mp(i),this.tn.bp(i,this.Zv))}xp(){return this.Gv.bitmapSize}Sp(t,i,n){const s=this.xp();s.width>0&&s.height>0&&t.drawImage(this.Gv.canvasElement,i,n)}bt(){var t;null===(t=this.Ii)||void 0===t||t.Ha()}Qv(t){if(null===this.Ii||this.Ii.Ni()||!this.cn.handleScale.axisPressedMouseMove.price)return;const i=this.tn.$v().$t(),n=this.tn.fp();this.Nv=!0,i.O_(n,this.Ii,t.localY)}tp(t){if(null===this.Ii||!this.cn.handleScale.axisPressedMouseMove.price)return;const i=this.tn.$v().$t(),n=this.tn.fp(),s=this.Ii;i.B_(n,s,t.localY)}ip(){if(null===this.Ii||!this.cn.handleScale.axisPressedMouseMove.price)return;const t=this.tn.$v().$t(),i=this.tn.fp(),n=this.Ii;this.Nv&&(this.Nv=!1,t.V_(i,n))}np(t){if(null===this.Ii||!this.cn.handleScale.axisPressedMouseMove.price)return;const i=this.tn.$v().$t(),n=this.tn.fp();this.Nv=!1,i.V_(n,this.Ii)}sp(t){this.cn.handleScale.axisDoubleClickReset.price&&this.nr()}ep(t){if(null===this.Ii)return;!this.tn.$v().$t().W().handleScale.axisPressedMouseMove.price||this.Ii.Mh()||this.Ii.Bo()||this.kp(1)}rp(t){this.kp(0)}up(){const t=[],i=null===this.Ii?void 0:this.Ii;return(n=>{for(let s=0;s{t.fillStyle=n.borderColor;const l=Math.max(1,Math.floor(h)),a=Math.floor(.5*h),o=Math.round(s.T*r);t.beginPath();for(const n of i)t.rect(Math.floor(e*r),Math.round(n.La*h)-a,o,l);t.fill()})),t.useMediaCoordinateSpace((({context:t})=>{var r;t.font=this._p(),t.fillStyle=null!==(r=n.textColor)&&void 0!==r?r:this.ko.textColor,t.textAlign=this.qv?"right":"left",t.textBaseline="middle";const h=this.qv?Math.round(e-s.A):Math.round(e+s.T+s.A),l=i.map((i=>this.Fv.Mi(t,i.so)));for(let n=i.length;n--;){const s=i[n];t.fillText(s.so,h,s.La+l[n])}}))}pp(){if(null===this.Lv||null===this.Ii)return;const t=[],i=this.Ii.Uo().slice(),n=this.tn.fp(),s=this.ap();this.Ii===n.pr()&&this.tn.fp().Uo().forEach((t=>{n.vr(t)&&i.push(t)}));const e=this.Ii;i.forEach((i=>{i.Rn(n,e).forEach((i=>{i.Bi(null),i.Vi()&&t.push(i)}))})),t.forEach((t=>t.Bi(t.ki())));this.Ii.W().alignLabels&&this.yp(t,s)}yp(t,i){if(null===this.Lv)return;const n=this.Lv.height/2,s=t.filter((t=>t.ki()<=n)),e=t.filter((t=>t.ki()>n));s.sort(((t,i)=>i.ki()-t.ki())),e.sort(((t,i)=>t.ki()-i.ki()));for(const n of t){const t=Math.floor(n.At(i)/2),s=n.ki();s>-t&&sthis.Lv.height-t&&s{if(i.Ai()){i.gt(f(this.Ii)).X(t,n,this.Fv,s)}}))}Mp(t){if(null===this.Lv||null===this.Ii)return;const i=this.tn.$v().$t(),n=[],s=this.tn.fp(),e=i.Zc().Rn(s,this.Ii);e.length&&n.push(e);const r=this.ap(),h=this.qv?"right":"left";n.forEach((i=>{i.forEach((i=>{i.gt(f(this.Ii)).X(t,r,this.Fv,h)}))}))}kp(t){this.Kv.style.cursor=1===t?"ns-resize":"default"}fo(){const t=this.op();this.Wv{this.jv||null===this.Np||this.$i().Uh()},this.Uv=()=>{this.jv||null===this.Np||this.$i().Uh()},this.Qd=t,this.Np=i,this.Np.W_().l(this.Fp.bind(this),this,!0),this.Wp=document.createElement("td"),this.Wp.style.padding="0",this.Wp.style.position="relative";const n=document.createElement("div");n.style.width="100%",n.style.height="100%",n.style.position="relative",n.style.overflow="hidden",this.jp=document.createElement("td"),this.jp.style.padding="0",this.Hp=document.createElement("td"),this.Hp.style.padding="0",this.Wp.appendChild(n),this.Gv=ds(n,Jn({width:16,height:16})),this.Gv.subscribeSuggestedBitmapSizeChanged(this.Hv);const s=this.Gv.canvasElement;s.style.position="absolute",s.style.zIndex="1",s.style.left="0",s.style.top="0",this.Jv=ds(n,Jn({width:16,height:16})),this.Jv.subscribeSuggestedBitmapSizeChanged(this.Uv);const e=this.Jv.canvasElement;e.style.position="absolute",e.style.zIndex="2",e.style.left="0",e.style.top="0",this.$p=document.createElement("tr"),this.$p.appendChild(this.jp),this.$p.appendChild(this.Wp),this.$p.appendChild(this.Hp),this.Up(),this.hp=new ws(this.Jv.canvasElement,this,{ev:()=>null===this.zp&&!this.Qd.W().handleScroll.vertTouchDrag,rv:()=>null===this.zp&&!this.Qd.W().handleScroll.horzTouchDrag})}S(){null!==this.Cp&&this.Cp.S(),null!==this.Tp&&this.Tp.S(),this.Pp=null,this.Jv.unsubscribeSuggestedBitmapSizeChanged(this.Uv),fs(this.Jv.canvasElement),this.Jv.dispose(),this.Gv.unsubscribeSuggestedBitmapSizeChanged(this.Hv),fs(this.Gv.canvasElement),this.Gv.dispose(),null!==this.Np&&this.Np.W_().p(this),this.hp.S()}fp(){return f(this.Np)}qp(t){var i,n;null!==this.Np&&this.Np.W_().p(this),this.Np=t,null!==this.Np&&this.Np.W_().l(As.prototype.Fp.bind(this),this,!0),this.Up(),this.Qd.Yp().indexOf(this)===this.Qd.Yp().length-1?(this.Pp=null!==(i=this.Pp)&&void 0!==i?i:new cs(this.Wp,this.Qd),this.Pp.bt()):(null===(n=this.Pp)||void 0===n||n.if(),this.Pp=null)}$v(){return this.Qd}lp(){return this.$p}Up(){if(null!==this.Np&&(this.Zp(),0!==this.$i().wt().length)){if(null!==this.Cp){const t=this.Np.R_();this.Cp.Gi(f(t))}if(null!==this.Tp){const t=this.Np.D_();this.Tp.Gi(f(t))}}}Xp(){null!==this.Cp&&this.Cp.bt(),null!==this.Tp&&this.Tp.bt()}M_(){return null!==this.Np?this.Np.M_():0}x_(t){this.Np&&this.Np.x_(t)}Qf(t){if(!this.Np)return;this.Kp();const i=t.localX,n=t.localY;this.Gp(i,n,t)}bv(t){this.Kp(),this.Jp(),this.Gp(t.localX,t.localY,t)}tv(t){var i;if(!this.Np)return;this.Kp();const n=t.localX,s=t.localY;this.Gp(n,s,t);const e=this.wr(n,s);this.Qd.Qp(null!==(i=null==e?void 0:e.Iv)&&void 0!==i?i:null),this.$i().jc(e&&{Hc:e.Hc,zv:e.zv})}dv(t){null!==this.Np&&(this.Kp(),this.tm(t))}qf(t){null!==this.Np&&this.im(this.Bp,t)}jf(t){this.qf(t)}av(t){this.Kp(),this.nm(t),this.Gp(t.localX,t.localY,t)}cv(t){null!==this.Np&&(this.Kp(),this.Ap=!1,this.sm(t))}uv(t){null!==this.Np&&this.tm(t)}Rv(t){if(this.Ap=!0,null===this.zp){const i={x:t.localX,y:t.localY};this.rm(i,i,t)}}Pv(t){null!==this.Np&&(this.Kp(),this.Np.$t().jc(null),this.hm())}lm(){return this.Op}am(){return this.Bp}xv(){this.Vp=1,this.$i().Un()}Sv(t,i){if(!this.Qd.W().handleScale.pinch)return;const n=5*(i-this.Vp);this.Vp=i,this.$i().Qc(t.nt,n)}pv(t){this.Ap=!1,this.Ep=null!==this.zp,this.Jp();const i=this.$i().Zc();null!==this.zp&&i.yt()&&(this.Ip={x:i.Yt(),y:i.Zt()},this.zp={x:t.localX,y:t.localY})}hv(t){if(null===this.Np)return;const i=t.localX,n=t.localY;if(null===this.zp)this.nm(t);else{this.Ep=!1;const s=f(this.Ip),e=s.x+(i-this.zp.x),r=s.y+(n-this.zp.y);this.Gp(e,r,t)}}_v(t){0===this.$v().W().trackingMode.exitMode&&(this.Ep=!0),this.om(),this.sm(t)}wr(t,i){const n=this.Np;return null===n?null:function(t,i,n){const s=t.Uo(),e=function(t,i,n){var s,e;let r,h;for(const o of t){const t=null!==(e=null===(s=o.va)||void 0===s?void 0:s.call(o,i,n))&&void 0!==e?e:[];for(const i of t)l=i.zOrder,(!(a=null==r?void 0:r.zOrder)||"top"===l&&"top"!==a||"normal"===l&&"bottom"===a)&&(r=i,h=o)}var l,a;return r&&h?{Ev:r,Hc:h}:null}(s,i,n);if("top"===(null==e?void 0:e.Ev.zOrder))return ys(e);for(const r of s){if(e&&e.Hc===r&&"bottom"!==e.Ev.zOrder&&!e.Ev.isBackground)return ys(e);const s=Cs(r.Pn(t),i,n);if(null!==s)return{Hc:r,Vv:s.Vv,zv:s.zv};if(e&&e.Hc===r&&"bottom"!==e.Ev.zOrder&&e.Ev.isBackground)return ys(e)}return(null==e?void 0:e.Ev)?ys(e):null}(n,t,i)}_m(t,i){f("left"===i?this.Cp:this.Tp).cp(Jn({width:t,height:this.Lv.height}))}um(){return this.Lv}cp(t){Qn(this.Lv,t)||(this.Lv=t,this.jv=!0,this.Gv.resizeCanvasElement(t),this.Jv.resizeCanvasElement(t),this.jv=!1,this.Wp.style.width=t.width+"px",this.Wp.style.height=t.height+"px")}dm(){const t=f(this.Np);t.P_(t.R_()),t.P_(t.D_());for(const i of t.Va())if(t.vr(i)){const n=i.Dt();null!==n&&t.P_(n),i.On()}}xp(){return this.Gv.bitmapSize}Sp(t,i,n){const s=this.xp();s.width>0&&s.height>0&&t.drawImage(this.Gv.canvasElement,i,n)}vp(t){if(0===t)return;if(null===this.Np)return;if(t>1&&this.dm(),null!==this.Cp&&this.Cp.vp(t),null!==this.Tp&&this.Tp.vp(t),1!==t){this.Gv.applySuggestedBitmapSize();const t=es(this.Gv);null!==t&&(t.useBitmapCoordinateSpace((t=>{this.mp(t)})),this.Np&&(this.fm(t,Ds),this.vm(t),this.pm(t),this.fm(t,Os),this.fm(t,Bs)))}this.Jv.applySuggestedBitmapSize();const i=es(this.Jv);null!==i&&(i.useBitmapCoordinateSpace((({context:t,bitmapSize:i})=>{t.clearRect(0,0,i.width,i.height)})),this.bm(i),this.fm(i,Vs))}wm(){return this.Cp}gm(){return this.Tp}bp(t,i){this.fm(t,i)}Fp(){null!==this.Np&&this.Np.W_().p(this),this.Np=null}tm(t){this.im(this.Op,t)}im(t,i){const n=i.localX,s=i.localY;t.M()&&t.m(this.$i().St().Nu(n),{x:n,y:s},i)}mp({context:t,bitmapSize:i}){const{width:n,height:s}=i,e=this.$i(),r=e.q(),h=e.bd();r===h?Y(t,0,0,n,s,h):K(t,0,0,n,s,r,h)}vm(t){const i=f(this.Np).j_().qh().gt();null!==i&&i.X(t,!1)}pm(t){const i=this.$i().Yc();this.Mm(t,Os,vs,i),this.Mm(t,Os,ps,i)}bm(t){this.Mm(t,Os,ps,this.$i().Zc())}fm(t,i){const n=f(this.Np).Uo();for(const s of n)this.Mm(t,i,vs,s);for(const s of n)this.Mm(t,i,ps,s)}Mm(t,i,n,s){const e=f(this.Np),r=e.$t().Wc(),h=null!==r&&r.Hc===s,l=null!==r&&h&&void 0!==r.zv?r.zv.Mr:void 0;ms(i,(i=>n(i,t,h,l)),s,e)}Zp(){if(null===this.Np)return;const t=this.Qd,i=this.Np.R_().W().visible,n=this.Np.D_().W().visible;i||null===this.Cp||(this.jp.removeChild(this.Cp.lp()),this.Cp.S(),this.Cp=null),n||null===this.Tp||(this.Hp.removeChild(this.Tp.lp()),this.Tp.S(),this.Tp=null);const s=t.$t().ud();i&&null===this.Cp&&(this.Cp=new Rs(this,t.W(),s,"left"),this.jp.appendChild(this.Cp.lp())),n&&null===this.Tp&&(this.Tp=new Rs(this,t.W(),s,"right"),this.Hp.appendChild(this.Tp.lp()))}xm(t){return t.Dv&&this.Ap||null!==this.zp}Sm(t){return Math.max(0,Math.min(t,this.Lv.width-1))}km(t){return Math.max(0,Math.min(t,this.Lv.height-1))}Gp(t,i,n){this.$i().ld(this.Sm(t),this.km(i),n,f(this.Np))}hm(){this.$i().od()}om(){this.Ep&&(this.zp=null,this.hm())}rm(t,i,n){this.zp=t,this.Ep=!1,this.Gp(i.x,i.y,n);const s=this.$i().Zc();this.Ip={x:s.Yt(),y:s.Zt()}}$i(){return this.Qd.$t()}sm(t){if(!this.Dp)return;const i=this.$i(),n=this.fp();if(i.E_(n,n.vn()),this.Rp=null,this.Dp=!1,i.ed(),null!==this.Lp){const t=performance.now(),n=i.St();this.Lp.Or(n.Hu(),t),this.Lp.Qu(t)||i.Zn(this.Lp)}}Kp(){this.zp=null}Jp(){if(!this.Np)return;if(this.$i().Un(),document.activeElement!==document.body&&document.activeElement!==document.documentElement)f(document.activeElement).blur();else{const t=document.getSelection();null!==t&&t.removeAllRanges()}!this.Np.vn().Ni()&&this.$i().St().Ni()}nm(t){if(null===this.Np)return;const i=this.$i(),n=i.St();if(n.Ni())return;const s=this.Qd.W(),e=s.handleScroll,r=s.kineticScroll;if((!e.pressedMouseMove||t.Dv)&&(!e.horzTouchDrag&&!e.vertTouchDrag||!t.Dv))return;const h=this.Np.vn(),l=performance.now();if(null!==this.Rp||this.xm(t)||(this.Rp={x:t.clientX,y:t.clientY,Bd:l,ym:t.localX,Cm:t.localY}),null!==this.Rp&&!this.Dp&&(this.Rp.x!==t.clientX||this.Rp.y!==t.clientY)){if(t.Dv&&r.touch||!t.Dv&&r.mouse){const t=n.le();this.Lp=new us(.2/t,7/t,.997,15/t),this.Lp.Yd(n.Hu(),this.Rp.Bd)}else this.Lp=null;h.Ni()||i.A_(this.Np,h,t.localY),i.nd(t.localX),this.Dp=!0}this.Dp&&(h.Ni()||i.z_(this.Np,h,t.localY),i.sd(t.localX),null!==this.Lp&&this.Lp.Yd(n.Hu(),l))}}class zs{constructor(t,i,n,s,e){this.ft=!0,this.Lv=Jn({width:0,height:0}),this.Hv=()=>this.vp(3),this.qv="left"===t,this.Bc=n.ud,this.cn=i,this.Tm=s,this.Pm=e,this.Kv=document.createElement("div"),this.Kv.style.width="25px",this.Kv.style.height="100%",this.Kv.style.overflow="hidden",this.Gv=ds(this.Kv,Jn({width:16,height:16})),this.Gv.subscribeSuggestedBitmapSizeChanged(this.Hv)}S(){this.Gv.unsubscribeSuggestedBitmapSizeChanged(this.Hv),fs(this.Gv.canvasElement),this.Gv.dispose()}lp(){return this.Kv}um(){return this.Lv}cp(t){Qn(this.Lv,t)||(this.Lv=t,this.Gv.resizeCanvasElement(t),this.Kv.style.width=`${t.width}px`,this.Kv.style.height=`${t.height}px`,this.ft=!0)}vp(t){if(t<3&&!this.ft)return;if(0===this.Lv.width||0===this.Lv.height)return;this.ft=!1,this.Gv.applySuggestedBitmapSize();const i=es(this.Gv);null!==i&&i.useBitmapCoordinateSpace((t=>{this.mp(t),this.ze(t)}))}xp(){return this.Gv.bitmapSize}Sp(t,i,n){const s=this.xp();s.width>0&&s.height>0&&t.drawImage(this.Gv.canvasElement,i,n)}ze({context:t,bitmapSize:i,horizontalPixelRatio:n,verticalPixelRatio:s}){if(!this.Tm())return;t.fillStyle=this.cn.timeScale.borderColor;const e=Math.floor(this.Bc.W().C*n),r=Math.floor(this.Bc.W().C*s),h=this.qv?i.width-e:0;t.fillRect(h,0,e,r)}mp({context:t,bitmapSize:i}){Y(t,0,0,i.width,i.height,this.Pm())}}function Es(t){return i=>{var n,s;return null!==(s=null===(n=i.fa)||void 0===n?void 0:n.call(i,t))&&void 0!==s?s:[]}}const Is=Es("normal"),Ls=Es("top"),Ns=Es("bottom");class Fs{constructor(t,i){this.Rm=null,this.Dm=null,this.k=null,this.Om=!1,this.Lv=Jn({width:0,height:0}),this.Bm=new C,this.Fv=new Jt(5),this.jv=!1,this.Hv=()=>{this.jv||this.Qd.$t().Uh()},this.Uv=()=>{this.jv||this.Qd.$t().Uh()},this.Qd=t,this.q_=i,this.cn=t.W().layout,this.Xd=document.createElement("tr"),this.Vm=document.createElement("td"),this.Vm.style.padding="0",this.Am=document.createElement("td"),this.Am.style.padding="0",this.Kv=document.createElement("td"),this.Kv.style.height="25px",this.Kv.style.padding="0",this.zm=document.createElement("div"),this.zm.style.width="100%",this.zm.style.height="100%",this.zm.style.position="relative",this.zm.style.overflow="hidden",this.Kv.appendChild(this.zm),this.Gv=ds(this.zm,Jn({width:16,height:16})),this.Gv.subscribeSuggestedBitmapSizeChanged(this.Hv);const n=this.Gv.canvasElement;n.style.position="absolute",n.style.zIndex="1",n.style.left="0",n.style.top="0",this.Jv=ds(this.zm,Jn({width:16,height:16})),this.Jv.subscribeSuggestedBitmapSizeChanged(this.Uv);const s=this.Jv.canvasElement;s.style.position="absolute",s.style.zIndex="2",s.style.left="0",s.style.top="0",this.Xd.appendChild(this.Vm),this.Xd.appendChild(this.Kv),this.Xd.appendChild(this.Am),this.Em(),this.Qd.$t().g_().l(this.Em.bind(this),this),this.hp=new ws(this.Jv.canvasElement,this,{ev:()=>!0,rv:()=>!this.Qd.W().handleScroll.horzTouchDrag})}S(){this.hp.S(),null!==this.Rm&&this.Rm.S(),null!==this.Dm&&this.Dm.S(),this.Jv.unsubscribeSuggestedBitmapSizeChanged(this.Uv),fs(this.Jv.canvasElement),this.Jv.dispose(),this.Gv.unsubscribeSuggestedBitmapSizeChanged(this.Hv),fs(this.Gv.canvasElement),this.Gv.dispose()}lp(){return this.Xd}Im(){return this.Rm}Lm(){return this.Dm}bv(t){if(this.Om)return;this.Om=!0;const i=this.Qd.$t();!i.St().Ni()&&this.Qd.W().handleScale.axisPressedMouseMove.time&&i.Jc(t.localX)}pv(t){this.bv(t)}wv(){const t=this.Qd.$t();!t.St().Ni()&&this.Om&&(this.Om=!1,this.Qd.W().handleScale.axisPressedMouseMove.time&&t.hd())}av(t){const i=this.Qd.$t();!i.St().Ni()&&this.Qd.W().handleScale.axisPressedMouseMove.time&&i.rd(t.localX)}hv(t){this.av(t)}cv(){this.Om=!1;const t=this.Qd.$t();t.St().Ni()&&!this.Qd.W().handleScale.axisPressedMouseMove.time||t.hd()}_v(){this.cv()}qf(){this.Qd.W().handleScale.axisDoubleClickReset.time&&this.Qd.$t().Kn()}jf(){this.qf()}Qf(){this.Qd.$t().W().handleScale.axisPressedMouseMove.time&&this.kp(1)}Pv(){this.kp(0)}um(){return this.Lv}Nm(){return this.Bm}Fm(t,i,n){Qn(this.Lv,t)||(this.Lv=t,this.jv=!0,this.Gv.resizeCanvasElement(t),this.Jv.resizeCanvasElement(t),this.jv=!1,this.Kv.style.width=`${t.width}px`,this.Kv.style.height=`${t.height}px`,this.Bm.m(t)),null!==this.Rm&&this.Rm.cp(Jn({width:i,height:t.height})),null!==this.Dm&&this.Dm.cp(Jn({width:n,height:t.height}))}Wm(){const t=this.jm();return Math.ceil(t.C+t.T+t.P+t.L+t.V+t.Hm)}bt(){this.Qd.$t().St().Ha()}xp(){return this.Gv.bitmapSize}Sp(t,i,n){const s=this.xp();s.width>0&&s.height>0&&t.drawImage(this.Gv.canvasElement,i,n)}vp(t){if(0===t)return;if(1!==t){this.Gv.applySuggestedBitmapSize();const i=es(this.Gv);null!==i&&(i.useBitmapCoordinateSpace((t=>{this.mp(t),this.ze(t),this.$m(i,Ns)})),this.wp(i),this.$m(i,Is)),null!==this.Rm&&this.Rm.vp(t),null!==this.Dm&&this.Dm.vp(t)}this.Jv.applySuggestedBitmapSize();const i=es(this.Jv);null!==i&&(i.useBitmapCoordinateSpace((({context:t,bitmapSize:i})=>{t.clearRect(0,0,i.width,i.height)})),this.Um([...this.Qd.$t().wt(),this.Qd.$t().Zc()],i),this.$m(i,Ls))}$m(t,i){const n=this.Qd.$t().wt();for(const s of n)ms(i,(i=>vs(i,t,!1,void 0)),s,void 0);for(const s of n)ms(i,(i=>ps(i,t,!1,void 0)),s,void 0)}mp({context:t,bitmapSize:i}){Y(t,0,0,i.width,i.height,this.Qd.$t().bd())}ze({context:t,bitmapSize:i,verticalPixelRatio:n}){if(this.Qd.W().timeScale.borderVisible){t.fillStyle=this.qm();const s=Math.max(1,Math.floor(this.jm().C*n));t.fillRect(0,0,i.width,s)}}wp(t){const i=this.Qd.$t().St(),n=i.Ha();if(!n||0===n.length)return;const s=this.q_.maxTickMarkWeight(n),e=this.jm(),r=i.W();r.borderVisible&&r.ticksVisible&&t.useBitmapCoordinateSpace((({context:t,horizontalPixelRatio:i,verticalPixelRatio:s})=>{t.strokeStyle=this.qm(),t.fillStyle=this.qm();const r=Math.max(1,Math.floor(i)),h=Math.floor(.5*i);t.beginPath();const l=Math.round(e.T*s);for(let s=n.length;s--;){const e=Math.round(n[s].coord*i);t.rect(e-h,0,r,l)}t.fill()})),t.useMediaCoordinateSpace((({context:t})=>{const i=e.C+e.T+e.L+e.P/2;t.textAlign="center",t.textBaseline="middle",t.fillStyle=this.$(),t.font=this._p();for(const e of n)if(e.weight=s){const n=e.needAlignCoordinate?this.Ym(t,e.coord,e.label):e.coord;t.fillText(e.label,n,i)}}))}Ym(t,i,n){const s=this.Fv.xi(t,n),e=s/2,r=Math.floor(i-e)+.5;return r<0?i+=Math.abs(0-r):r+s>this.Lv.width&&(i-=Math.abs(this.Lv.width-(r+s))),i}Um(t,i){const n=this.jm();for(const s of t)for(const t of s.Qi())t.gt().X(i,n)}qm(){return this.Qd.W().timeScale.borderColor}$(){return this.cn.textColor}j(){return this.cn.fontSize}_p(){return E(this.j(),this.cn.fontFamily)}Zm(){return E(this.j(),this.cn.fontFamily,"bold")}jm(){null===this.k&&(this.k={C:1,N:NaN,L:NaN,V:NaN,ji:NaN,T:5,P:NaN,R:"",Wi:new Jt,Hm:0});const t=this.k,i=this._p();if(t.R!==i){const n=this.j();t.P=n,t.R=i,t.L=3*n/12,t.V=3*n/12,t.ji=9*n/12,t.N=0,t.Hm=4*n/12,t.Wi.nr()}return this.k}kp(t){this.Kv.style.cursor=1===t?"ew-resize":"default"}Em(){const t=this.Qd.$t(),i=t.W();i.leftPriceScale.visible||null===this.Rm||(this.Vm.removeChild(this.Rm.lp()),this.Rm.S(),this.Rm=null),i.rightPriceScale.visible||null===this.Dm||(this.Am.removeChild(this.Dm.lp()),this.Dm.S(),this.Dm=null);const n={ud:this.Qd.$t().ud()},s=()=>i.leftPriceScale.borderVisible&&t.St().W().borderVisible,e=()=>t.bd();i.leftPriceScale.visible&&null===this.Rm&&(this.Rm=new zs("left",i,n,s,e),this.Vm.appendChild(this.Rm.lp())),i.rightPriceScale.visible&&null===this.Dm&&(this.Dm=new zs("right",i,n,s,e),this.Am.appendChild(this.Dm.lp()))}}const Ws=!!rs&&!!navigator.userAgentData&&navigator.userAgentData.brands.some((t=>t.brand.includes("Chromium")))&&!!rs&&((null===(js=null===navigator||void 0===navigator?void 0:navigator.userAgentData)||void 0===js?void 0:js.platform)?"Windows"===navigator.userAgentData.platform:navigator.userAgent.toLowerCase().indexOf("win")>=0);var js;class Hs{constructor(t,i,n){var s;this.Xm=[],this.Km=0,this.ho=0,this.__=0,this.Gm=0,this.Jm=0,this.Qm=null,this.tb=!1,this.Op=new C,this.Bp=new C,this.Rc=new C,this.ib=null,this.nb=null,this.Jd=t,this.cn=i,this.q_=n,this.Xd=document.createElement("div"),this.Xd.classList.add("tv-lightweight-charts"),this.Xd.style.overflow="hidden",this.Xd.style.direction="ltr",this.Xd.style.width="100%",this.Xd.style.height="100%",(s=this.Xd).style.userSelect="none",s.style.webkitUserSelect="none",s.style.msUserSelect="none",s.style.MozUserSelect="none",s.style.webkitTapHighlightColor="transparent",this.sb=document.createElement("table"),this.sb.setAttribute("cellspacing","0"),this.Xd.appendChild(this.sb),this.eb=this.rb.bind(this),$s(this.cn)&&this.hb(!0),this.$i=new Vn(this.Oc.bind(this),this.cn,n),this.$t().Xc().l(this.lb.bind(this),this),this.ab=new Fs(this,this.q_),this.sb.appendChild(this.ab.lp());const e=i.autoSize&&this.ob();let r=this.cn.width,h=this.cn.height;if(e||0===r||0===h){const i=t.getBoundingClientRect();r=r||i.width,h=h||i.height}this._b(r,h),this.ub(),t.appendChild(this.Xd),this.cb(),this.$i.St().ec().l(this.$i.Kl.bind(this.$i),this),this.$i.g_().l(this.$i.Kl.bind(this.$i),this)}$t(){return this.$i}W(){return this.cn}Yp(){return this.Xm}fb(){return this.ab}S(){this.hb(!1),0!==this.Km&&window.cancelAnimationFrame(this.Km),this.$i.Xc().p(this),this.$i.St().ec().p(this),this.$i.g_().p(this),this.$i.S();for(const t of this.Xm)this.sb.removeChild(t.lp()),t.lm().p(this),t.am().p(this),t.S();this.Xm=[],f(this.ab).S(),null!==this.Xd.parentElement&&this.Xd.parentElement.removeChild(this.Xd),this.Rc.S(),this.Op.S(),this.Bp.S(),this.pb()}_b(t,i,n=!1){if(this.ho===i&&this.__===t)return;const s=function(t){const i=Math.floor(t.width),n=Math.floor(t.height);return Jn({width:i-i%2,height:n-n%2})}(Jn({width:t,height:i}));this.ho=s.height,this.__=s.width;const e=this.ho+"px",r=this.__+"px";f(this.Xd).style.height=e,f(this.Xd).style.width=r,this.sb.style.height=e,this.sb.style.width=r,n?this.mb(lt.es(),performance.now()):this.$i.Kl()}vp(t){void 0===t&&(t=lt.es());for(let i=0;i{let s=0;for(let e=0;e{f("left"===i?this.ab.Im():this.ab.Lm()).Sp(f(t),n,s)};if(this.cn.timeScale.visible){const i=this.ab.xp();if(null!==t){let e=0;this.xb()&&(r("left",e,n),e=f(s.wm()).xp().width),this.ab.Sp(t,e,n),e+=i.width,this.Sb()&&r("right",e,n)}n+=i.height}return Jn({width:i,height:n})}Pb(){let t=0,i=0,n=0;for(const s of this.Xm)this.xb()&&(i=Math.max(i,f(s.wm()).op(),this.cn.leftPriceScale.minimumWidth)),this.Sb()&&(n=Math.max(n,f(s.gm()).op(),this.cn.rightPriceScale.minimumWidth)),t+=s.M_();i=as(i),n=as(n);const s=this.__,e=this.ho,r=Math.max(s-i-n,0),h=this.cn.timeScale.visible;let l=h?Math.max(this.ab.Wm(),this.cn.timeScale.minimumHeight):0;var a;l=(a=l)+a%2;const o=0+l,_=e{t.Xp()})),3===(null===(n=this.Qm)||void 0===n?void 0:n.jn())&&(this.Qm.ts(t),this.Db(),this.Ob(this.Qm),this.Bb(this.Qm,i),t=this.Qm,this.Qm=null)),this.vp(t)}Bb(t,i){for(const n of t.Qn())this.ns(n,i)}Ob(t){const i=this.$i.qc();for(let n=0;n{if(this.tb=!1,this.Km=0,null!==this.Qm){const i=this.Qm;this.Qm=null,this.mb(i,t);for(const n of i.Qn())if(5===n.qn&&!n.Ot.Qu(t)){this.$t().Zn(n.Ot);break}}})))}Db(){this.ub()}ub(){const t=this.$i.qc(),i=t.length,n=this.Xm.length;for(let t=i;t{const n=i.zn().ll(t);null!==n&&e.set(i,n)}))}let r;if(null!==t){const i=null===(s=this.$i.St().Ui(t))||void 0===s?void 0:s.originalTime;void 0!==i&&(r=i)}const h=this.$t().Wc(),l=null!==h&&h.Hc instanceof Yi?h.Hc:void 0,a=null!==h&&void 0!==h.zv?h.zv.gr:void 0;return{Eb:r,ee:null!=t?t:void 0,Ib:null!=i?i:void 0,Lb:l,Nb:e,Fb:a,Wb:null!=n?n:void 0}}Vb(t,i,n){this.Op.m((()=>this.zb(t,i,n)))}Ab(t,i,n){this.Bp.m((()=>this.zb(t,i,n)))}lb(t,i,n){this.Rc.m((()=>this.zb(t,i,n)))}cb(){const t=this.cn.timeScale.visible?"":"none";this.ab.lp().style.display=t}xb(){return this.Xm[0].fp().R_().W().visible}Sb(){return this.Xm[0].fp().D_().W().visible}ob(){return"ResizeObserver"in window&&(this.ib=new ResizeObserver((t=>{const i=t.find((t=>t.target===this.Jd));i&&this._b(i.contentRect.width,i.contentRect.height)})),this.ib.observe(this.Jd,{box:"border-box"}),!0)}pb(){null!==this.ib&&this.ib.disconnect(),this.ib=null}}function $s(t){return Boolean(t.handleScroll.mouseWheel||t.handleScale.mouseWheel)}function Us(t){return function(t){return void 0!==t.open}(t)||function(t){return void 0!==t.value}(t)}function qs(t,i){var n={};for(var s in t)Object.prototype.hasOwnProperty.call(t,s)&&i.indexOf(s)<0&&(n[s]=t[s]);if(null!=t&&"function"==typeof Object.getOwnPropertySymbols){var e=0;for(s=Object.getOwnPropertySymbols(t);efunction(t,i){return i?i(t):void 0===(n=t).open&&void 0===n.value;var n}(s,h)?te({ot:i,ee:n,Eb:e},s):te(t(i,n,s,e,r),s)}function ne(t){return{Candlestick:ie(Gs),Bar:ie(Ks),Area:ie(Zs),Baseline:ie(Xs),Histogram:ie(Ys),Line:ie(Ys),Custom:ie(Js)}[t]}function se(t){return{ee:0,Hb:new Map,la:t}}function ee(t,i){if(void 0!==t&&0!==t.length)return{$b:i.key(t[0].ot),Ub:i.key(t[t.length-1].ot)}}function re(t){let i;return t.forEach((t=>{void 0===i&&(i=t.Eb)})),d(i)}class he{constructor(t){this.qb=new Map,this.Yb=new Map,this.Zb=new Map,this.Xb=[],this.q_=t}S(){this.qb.clear(),this.Yb.clear(),this.Zb.clear(),this.Xb=[]}Kb(t,i){let n=0!==this.qb.size,s=!1;const e=this.Yb.get(t);if(void 0!==e)if(1===this.Yb.size)n=!1,s=!0,this.qb.clear();else for(const i of this.Xb)i.pointData.Hb.delete(t)&&(s=!0);let r=[];if(0!==i.length){const n=i.map((t=>t.time)),e=this.q_.createConverterToInternalObj(i),h=ne(t.Qh()),l=t.Ca(),a=t.Ta();r=i.map(((i,r)=>{const o=e(i.time),_=this.q_.key(o);let u=this.qb.get(_);void 0===u&&(u=se(o),this.qb.set(_,u),s=!0);const c=h(o,u.ee,i,n[r],l,a);return u.Hb.set(t,c),c}))}n&&this.Gb(),this.Jb(t,r);let h=-1;if(s){const t=[];this.qb.forEach((i=>{t.push({timeWeight:0,time:i.la,pointData:i,originalTime:re(i.Hb)})})),t.sort(((t,i)=>this.q_.key(t.time)-this.q_.key(i.time))),h=this.Qb(t)}return this.tw(t,h,function(t,i,n){const s=ee(t,n),e=ee(i,n);if(void 0!==s&&void 0!==e)return{ta:s.Ub>=e.Ub&&s.$b>=e.$b}}(this.Yb.get(t),e,this.q_))}vd(t){return this.Kb(t,[])}iw(t,i){const n=i;!function(t){void 0===t.Eb&&(t.Eb=t.time)}(n),this.q_.preprocessData(i);const s=this.q_.createConverterToInternalObj([i])(i.time),e=this.Zb.get(t);if(void 0!==e&&this.q_.key(s)this.q_.key(t.time)this.q_.key(s.ot)?Qs(i)&&n.push(i):Qs(i)?n[n.length-1]=i:n.splice(-1,1),this.Zb.set(t,i.ot)}Jb(t,i){0!==i.length?(this.Yb.set(t,i.filter(Qs)),this.Zb.set(t,i[i.length-1].ot)):(this.Yb.delete(t),this.Zb.delete(t))}Gb(){for(const t of this.Xb)0===t.pointData.Hb.size&&this.qb.delete(this.q_.key(t.time))}Qb(t){let i=-1;for(let n=0;n{0!==i.length&&(t=Math.max(t,i[i.length-1].ee))})),t}tw(t,i,n){const s={ew:new Map,St:{Lu:this.sw()}};if(-1!==i)this.Yb.forEach(((i,e)=>{s.ew.set(e,{$e:i,rw:e===t?n:void 0})})),this.Yb.has(t)||s.ew.set(t,{$e:[],rw:n}),s.St.hw=this.Xb,s.St.lw=i;else{const i=this.Yb.get(t);s.ew.set(t,{$e:i||[],rw:n})}return s}}function le(t,i){t.ee=i,t.Hb.forEach((t=>{t.ee=i}))}function ae(t){const i={value:t.Ot[3],time:t.Eb};return void 0!==t.jb&&(i.customValues=t.jb),i}function oe(t){const i=ae(t);return void 0!==t.O&&(i.color=t.O),i}function _e(t){const i=ae(t);return void 0!==t.lt&&(i.lineColor=t.lt),void 0!==t.Ps&&(i.topColor=t.Ps),void 0!==t.Rs&&(i.bottomColor=t.Rs),i}function ue(t){const i=ae(t);return void 0!==t.Re&&(i.topLineColor=t.Re),void 0!==t.De&&(i.bottomLineColor=t.De),void 0!==t.ke&&(i.topFillColor1=t.ke),void 0!==t.ye&&(i.topFillColor2=t.ye),void 0!==t.Ce&&(i.bottomFillColor1=t.Ce),void 0!==t.Te&&(i.bottomFillColor2=t.Te),i}function ce(t){const i={open:t.Ot[0],high:t.Ot[1],low:t.Ot[2],close:t.Ot[3],time:t.Eb};return void 0!==t.jb&&(i.customValues=t.jb),i}function de(t){const i=ce(t);return void 0!==t.O&&(i.color=t.O),i}function fe(t){const i=ce(t),{O:n,Bt:s,Xh:e}=t;return void 0!==n&&(i.color=n),void 0!==s&&(i.borderColor=s),void 0!==e&&(i.wickColor=e),i}function ve(t){return{Area:_e,Line:oe,Baseline:ue,Histogram:oe,Bar:de,Candlestick:fe,Custom:pe}[t]}function pe(t){const i=t.Eb;return Object.assign(Object.assign({},t.$e),{time:i})}const me={vertLine:{color:"#9598A1",width:1,style:3,visible:!0,labelVisible:!0,labelBackgroundColor:"#131722"},horzLine:{color:"#9598A1",width:1,style:3,visible:!0,labelVisible:!0,labelBackgroundColor:"#131722"},mode:1},be={vertLines:{color:"#D6DCDE",style:0,visible:!0},horzLines:{color:"#D6DCDE",style:0,visible:!0}},we={background:{type:"solid",color:"#FFFFFF"},textColor:"#191919",fontSize:12,fontFamily:z,attributionLogo:!0},ge={autoScale:!0,mode:0,invertScale:!1,alignLabels:!0,borderVisible:!0,borderColor:"#2B2B43",entireTextOnly:!1,visible:!1,ticksVisible:!1,scaleMargins:{bottom:.1,top:.2},minimumWidth:0},Me={rightOffset:0,barSpacing:6,minBarSpacing:.5,fixLeftEdge:!1,fixRightEdge:!1,lockVisibleTimeRangeOnResize:!1,rightBarStaysOnScroll:!1,borderVisible:!0,borderColor:"#2B2B43",visible:!0,timeVisible:!1,secondsVisible:!0,shiftVisibleRangeOnNewBar:!0,allowShiftVisibleRangeOnWhitespaceReplacement:!1,ticksVisible:!1,uniformDistribution:!1,minimumHeight:0,allowBoldLabels:!0},xe={color:"rgba(0, 0, 0, 0)",visible:!1,fontSize:48,fontFamily:z,fontStyle:"",text:"",horzAlign:"center",vertAlign:"center"};function Se(){return{width:0,height:0,autoSize:!1,layout:we,crosshair:me,grid:be,overlayPriceScales:Object.assign({},ge),leftPriceScale:Object.assign(Object.assign({},ge),{visible:!1}),rightPriceScale:Object.assign(Object.assign({},ge),{visible:!0}),timeScale:Me,watermark:xe,localization:{locale:rs?navigator.language:"",dateFormat:"dd MMM 'yy"},handleScroll:{mouseWheel:!0,pressedMouseMove:!0,horzTouchDrag:!0,vertTouchDrag:!0},handleScale:{axisPressedMouseMove:{time:!0,price:!0},axisDoubleClickReset:{time:!0,price:!0},mouseWheel:!0,pinch:!0},kineticScroll:{mouse:!1,touch:!0},trackingMode:{exitMode:1}}}class ke{constructor(t,i){this.aw=t,this.ow=i}applyOptions(t){this.aw.$t().$c(this.ow,t)}options(){return this.Ii().W()}width(){return ht(this.ow)?this.aw.Mb(this.ow):0}Ii(){return f(this.aw.$t().Uc(this.ow)).Dt}}function ye(t,i,n){const s=qs(t,["time","originalTime"]),e=Object.assign({time:i},s);return void 0!==n&&(e.originalTime=n),e}const Ce={color:"#FF0000",price:0,lineStyle:2,lineWidth:1,lineVisible:!0,axisLabelVisible:!0,title:"",axisLabelColor:"",axisLabelTextColor:""};class Te{constructor(t){this.Nh=t}applyOptions(t){this.Nh.$h(t)}options(){return this.Nh.W()}_w(){return this.Nh}}class Pe{constructor(t,i,n,s,e){this.uw=new C,this.Ls=t,this.cw=i,this.dw=n,this.q_=e,this.fw=s}S(){this.uw.S()}priceFormatter(){return this.Ls.ba()}priceToCoordinate(t){const i=this.Ls.Ct();return null===i?null:this.Ls.Dt().Rt(t,i.Ot)}coordinateToPrice(t){const i=this.Ls.Ct();return null===i?null:this.Ls.Dt().pn(t,i.Ot)}barsInLogicalRange(t){if(null===t)return null;const i=new Mn(new bn(t.from,t.to)).lu(),n=this.Ls.zn();if(n.Ni())return null;const s=n.ll(i.Bs(),1),e=n.ll(i.ui(),-1),r=f(n.el()),h=f(n.An());if(null!==s&&null!==e&&s.ee>e.ee)return{barsBefore:t.from-r,barsAfter:h-t.to};const l={barsBefore:null===s||s.ee===r?t.from-r:s.ee-r,barsAfter:null===e||e.ee===h?h-t.to:h-e.ee};return null!==s&&null!==e&&(l.from=s.Eb,l.to=e.Eb),l}setData(t){this.q_,this.Ls.Qh(),this.cw.pw(this.Ls,t),this.mw("full")}update(t){this.Ls.Qh(),this.cw.bw(this.Ls,t),this.mw("update")}dataByIndex(t,i){const n=this.Ls.zn().ll(t,i);if(null===n)return null;return ve(this.seriesType())(n)}data(){const t=ve(this.seriesType());return this.Ls.zn().ne().map((i=>t(i)))}subscribeDataChanged(t){this.uw.l(t)}unsubscribeDataChanged(t){this.uw.v(t)}setMarkers(t){this.q_;const i=t.map((t=>ye(t,this.q_.convertHorzItemToInternal(t.time),t.time)));this.Ls.na(i)}markers(){return this.Ls.sa().map((t=>ye(t,t.originalTime,void 0)))}applyOptions(t){this.Ls.$h(t)}options(){return B(this.Ls.W())}priceScale(){return this.dw.priceScale(this.Ls.Dt().Pa())}createPriceLine(t){const i=T(B(Ce),t),n=this.Ls.ea(i);return new Te(n)}removePriceLine(t){this.Ls.ra(t._w())}seriesType(){return this.Ls.Qh()}attachPrimitive(t){this.Ls.ka(t),t.attached&&t.attached({chart:this.fw,series:this,requestUpdate:()=>this.Ls.$t().Kl()})}detachPrimitive(t){this.Ls.ya(t),t.detached&&t.detached()}mw(t){this.uw.M()&&this.uw.m(t)}}class Re{constructor(t,i,n){this.ww=new C,this.mu=new C,this.Bm=new C,this.$i=t,this.yl=t.St(),this.ab=i,this.yl.nc().l(this.gw.bind(this)),this.yl.sc().l(this.Mw.bind(this)),this.ab.Nm().l(this.xw.bind(this)),this.q_=n}S(){this.yl.nc().p(this),this.yl.sc().p(this),this.ab.Nm().p(this),this.ww.S(),this.mu.S(),this.Bm.S()}scrollPosition(){return this.yl.Hu()}scrollToPosition(t,i){i?this.yl.Ju(t,1e3):this.$i.Jn(t)}scrollToRealTime(){this.yl.Gu()}getVisibleRange(){const t=this.yl.Ou();return null===t?null:{from:t.from.originalTime,to:t.to.originalTime}}setVisibleRange(t){const i={from:this.q_.convertHorzItemToInternal(t.from),to:this.q_.convertHorzItemToInternal(t.to)},n=this.yl.zu(i);this.$i.pd(n)}getVisibleLogicalRange(){const t=this.yl.Du();return null===t?null:{from:t.Bs(),to:t.ui()}}setVisibleLogicalRange(t){c(t.from<=t.to,"The from index cannot be after the to index."),this.$i.pd(t)}resetTimeScale(){this.$i.Kn()}fitContent(){this.$i.hc()}logicalToCoordinate(t){const i=this.$i.St();return i.Ni()?null:i.zt(t)}coordinateToLogical(t){return this.yl.Ni()?null:this.yl.Nu(t)}timeToCoordinate(t){const i=this.q_.convertHorzItemToInternal(t),n=this.yl.Oa(i,!1);return null===n?null:this.yl.zt(n)}coordinateToTime(t){const i=this.$i.St(),n=i.Nu(t),s=i.Ui(n);return null===s?null:s.originalTime}width(){return this.ab.um().width}height(){return this.ab.um().height}subscribeVisibleTimeRangeChange(t){this.ww.l(t)}unsubscribeVisibleTimeRangeChange(t){this.ww.v(t)}subscribeVisibleLogicalRangeChange(t){this.mu.l(t)}unsubscribeVisibleLogicalRangeChange(t){this.mu.v(t)}subscribeSizeChange(t){this.Bm.l(t)}unsubscribeSizeChange(t){this.Bm.v(t)}applyOptions(t){this.yl.$h(t)}options(){return Object.assign(Object.assign({},B(this.yl.W())),{barSpacing:this.yl.le()})}gw(){this.ww.M()&&this.ww.m(this.getVisibleRange())}Mw(){this.mu.M()&&this.mu.m(this.getVisibleLogicalRange())}xw(t){this.Bm.m(t.width,t.height)}}function De(t){if(void 0===t||"custom"===t.type)return;const i=t;void 0!==i.minMove&&void 0===i.precision&&(i.precision=function(t){if(t>=1)return 0;let i=0;for(;i<8;i++){const n=Math.round(t);if(Math.abs(n-t)<1e-8)return i;t*=10}return i}(i.minMove))}function Oe(t){return function(t){if(O(t.handleScale)){const i=t.handleScale;t.handleScale={axisDoubleClickReset:{time:i,price:i},axisPressedMouseMove:{time:i,price:i},mouseWheel:i,pinch:i}}else if(void 0!==t.handleScale){const{axisPressedMouseMove:i,axisDoubleClickReset:n}=t.handleScale;O(i)&&(t.handleScale.axisPressedMouseMove={time:i,price:i}),O(n)&&(t.handleScale.axisDoubleClickReset={time:n,price:n})}const i=t.handleScroll;O(i)&&(t.handleScroll={horzTouchDrag:i,vertTouchDrag:i,mouseWheel:i,pressedMouseMove:i})}(t),t}class Be{constructor(t,i,n){this.Sw=new Map,this.kw=new Map,this.yw=new C,this.Cw=new C,this.Tw=new C,this.Pw=new he(i);const s=void 0===n?B(Se()):T(B(Se()),Oe(n));this.q_=i,this.aw=new Hs(t,s,i),this.aw.lm().l((t=>{this.yw.M()&&this.yw.m(this.Rw(t()))}),this),this.aw.am().l((t=>{this.Cw.M()&&this.Cw.m(this.Rw(t()))}),this),this.aw.Xc().l((t=>{this.Tw.M()&&this.Tw.m(this.Rw(t()))}),this);const e=this.aw.$t();this.Dw=new Re(e,this.aw.fb(),this.q_)}remove(){this.aw.lm().p(this),this.aw.am().p(this),this.aw.Xc().p(this),this.Dw.S(),this.aw.S(),this.Sw.clear(),this.kw.clear(),this.yw.S(),this.Cw.S(),this.Tw.S(),this.Pw.S()}resize(t,i,n){this.autoSizeActive()||this.aw._b(t,i,n)}addCustomSeries(t,i){const n=v(t),s=Object.assign(Object.assign({},h),n.defaultOptions());return this.Ow("Custom",s,i,n)}addAreaSeries(t){return this.Ow("Area",s,t)}addBaselineSeries(t){return this.Ow("Baseline",e,t)}addBarSeries(t){return this.Ow("Bar",i,t)}addCandlestickSeries(i={}){return function(t){void 0!==t.borderColor&&(t.borderUpColor=t.borderColor,t.borderDownColor=t.borderColor),void 0!==t.wickColor&&(t.wickUpColor=t.wickColor,t.wickDownColor=t.wickColor)}(i),this.Ow("Candlestick",t,i)}addHistogramSeries(t){return this.Ow("Histogram",r,t)}addLineSeries(t){return this.Ow("Line",n,t)}removeSeries(t){const i=d(this.Sw.get(t)),n=this.Pw.vd(i);this.aw.$t().vd(i),this.Bw(n),this.Sw.delete(t),this.kw.delete(i)}pw(t,i){this.Bw(this.Pw.Kb(t,i))}bw(t,i){this.Bw(this.Pw.iw(t,i))}subscribeClick(t){this.yw.l(t)}unsubscribeClick(t){this.yw.v(t)}subscribeCrosshairMove(t){this.Tw.l(t)}unsubscribeCrosshairMove(t){this.Tw.v(t)}subscribeDblClick(t){this.Cw.l(t)}unsubscribeDblClick(t){this.Cw.v(t)}priceScale(t){return new ke(this.aw,t)}timeScale(){return this.Dw}applyOptions(t){this.aw.$h(Oe(t))}options(){return this.aw.W()}takeScreenshot(){return this.aw.wb()}autoSizeActive(){return this.aw.kb()}chartElement(){return this.aw.yb()}paneSize(){const t=this.aw.Tb();return{height:t.height,width:t.width}}setCrosshairPosition(t,i,n){const s=this.Sw.get(n);if(void 0===s)return;const e=this.aw.$t().dr(s);null!==e&&this.aw.$t().ad(t,i,e)}clearCrosshairPosition(){this.aw.$t().od(!0)}Ow(t,i,n={},s){De(n.priceFormat);const e=T(B(l),B(i),n),r=this.aw.$t().dd(t,e,s),h=new Pe(r,this,this,this,this.q_);return this.Sw.set(h,r),this.kw.set(r,h),h}Bw(t){const i=this.aw.$t();i._d(t.St.Lu,t.St.hw,t.St.lw),t.ew.forEach(((t,i)=>i.J(t.$e,t.rw))),i.Wu()}Vw(t){return d(this.kw.get(t))}Rw(t){const i=new Map;t.Nb.forEach(((t,n)=>{const s=n.Qh(),e=ve(s)(t);if("Custom"!==s)c(Us(e));else{const t=n.Ta();c(!t||!1===t(e))}i.set(this.Vw(n),e)}));const n=void 0!==t.Lb&&this.kw.has(t.Lb)?this.Vw(t.Lb):void 0;return{time:t.Eb,logical:t.ee,point:t.Ib,hoveredSeries:n,hoveredObjectId:t.Fb,seriesData:i,sourceEvent:t.Wb}}}function Ve(t,i,n){let s;if(D(t)){const i=document.getElementById(t);c(null!==i,`Cannot find element in DOM with id=${t}`),s=i}else s=t;const e=new Be(s,i,n);return i.setOptions(e.options()),e}const Ae=Object.assign(Object.assign({},l),h);var ze=Object.freeze({__proto__:null,get ColorType(){return On},get CrosshairMode(){return et},get LastPriceAnimationMode(){return Rn},get LineStyle(){return o},get LineType(){return a},get MismatchDirection(){return Vi},get PriceLineSource(){return Dn},get PriceScaleMode(){return cn},get TickMarkType(){return Bn},get TrackingModeExitMode(){return Pn},createChart:function(t,i){return Ve(t,new Gn,Gn.zd(i))},createChartEx:Ve,customSeriesDefaultOptions:Ae,defaultHorzScaleBehavior:function(){return Gn},isBusinessDay:An,isUTCTimestamp:zn,version:function(){return"4.2.3"}});window.LightweightCharts=ze}(); diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..943b497 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "Oil Trading Agent", + "short_name": "Oil · MT5", + "description": "MT5 Oil Trading Agent — Live-Dashboard", + "start_url": ".", + "display": "standalone", + "orientation": "portrait", + "background_color": "#0d1117", + "theme_color": "#0d1117", + "icons": [ + { + "src": "icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..3fcc153 --- /dev/null +++ b/web/style.css @@ -0,0 +1,418 @@ +:root{ + /* Flächen (Elevation-Leiter: bg < surface-2 < card) */ + --bg:#0a0d13; --card:#161b22; --surface-2:#0d1117; + --border:#2a323e; --line:#20262f; + /* Text (--text-* = Aliasse; mehrere Komponenten nutzten sie ohne Definition) */ + --bright:#e6edf3; --text-bright:#e6edf3; + --dim:#8b949e; --text-dim:#8b949e; + /* Akzente */ + --accent:#e8a000; --up:#3fb950; --down:#f85149; --blue:#58a6ff; + /* Radius / Schatten — einheitliche Design-Tokens */ + --r-sm:6px; --r:9px; --r-lg:13px; + --shadow:0 1px 2px rgba(0,0,0,.45), 0 8px 24px rgba(0,0,0,.28); + --shadow-sm:0 1px 2px rgba(0,0,0,.4); + --mono:"SF Mono",ui-monospace,"Cascadia Code","Courier New",monospace; +} +*{box-sizing:border-box;-webkit-tap-highlight-color:transparent} +html,body{margin:0;padding:0} +body{ + background:var(--bg); color:var(--bright); + /* Sans für Text/Labels + tabellarische Ziffern (saubere Zahlen-Ausrichtung); + Terminal-Charakter bleibt über Mono bei Kursen/Logs. */ + font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif; + font-variant-numeric:tabular-nums; + font-size:14px; line-height:1.45; + padding-bottom:env(safe-area-inset-bottom); + -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility; +} +/* Zahlen-lastige Werte in Mono (aligned, Trading-Terminal-Optik) */ +#hdr-price,#hdr-pnl,.px span,.tb-val,.tb-field input,.chart-last, +.hdr-clock,.gap-zone,.trade .p{font-family:var(--mono)} + +/* ── Header (scrollt unter den Tabs) ────── */ +header{ + background:var(--bg); + padding:12px 14px; + border-bottom:1px solid var(--border); +} +.sym-row{display:flex;align-items:center;justify-content:space-between;gap:8px} +#symbol{font-size:18px;font-weight:700;letter-spacing:.5px;flex:0 0 auto} +.hdr-right{display:flex;align-items:center;gap:8px;min-width:0} +.hdr-meta{display:flex;flex-direction:column;align-items:flex-end;line-height:1.25;min-width:0} +.hdr-meta .session-line{margin-top:1px} +.hdr-clock{font-size:10px;color:var(--dim);font-variant-numeric:tabular-nums; + white-space:nowrap} +.dot{width:11px;height:11px;border-radius:50%;display:inline-block} +.dot.on{background:var(--up);box-shadow:0 0 8px var(--up)} +.dot.off{background:var(--down);box-shadow:0 0 8px var(--down)} +.price-row{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px} +/* flex-basis 28% → 3 Boxen je Reihe, die 5. Box (KURS/G/V) bricht sauber um */ +.px{flex:1 1 28%;background:var(--card);border:1px solid var(--border); + border-radius:var(--r);padding:7px 10px;box-shadow:var(--shadow-sm)} +.px label{display:block;font-size:9px;color:var(--dim);letter-spacing:1px; + text-transform:uppercase} +.px span{font-size:16px;font-weight:600} +.px span.up{color:var(--up)} .px span.down{color:var(--down)} +/* KURS + G/V größer als die Konto-Kennzahlen (User-Vorgabe) */ +#hdr-price, #hdr-pnl{font-size:22px;font-weight:700} +#spread{color:var(--blue)} +.session-line{margin-top:3px;font-size:10px;font-weight:600} +.session-line.ok{color:var(--up)} +.session-line.warn{color:var(--accent)} +.session-line.dim{color:var(--dim)} + +/* ── Modul-Menü (Tabs, ganz oben & sticky) ─ */ +#tabs{ + position:sticky; top:0; z-index:6; + display:flex; gap:6px; + padding:calc(8px + env(safe-area-inset-top)) 10px 8px; + background:var(--bg); border-bottom:1px solid var(--border); + overflow-x:auto; -webkit-overflow-scrolling:touch; +} +.tab{ + flex:0 0 auto; padding:7px 14px; border-radius:var(--r); cursor:pointer; + background:var(--card); border:1px solid var(--border); + color:var(--dim); font-family:inherit; font-size:12px; font-weight:600; + transition:background .15s, color .15s, border-color .15s; +} +.tab:hover{color:var(--bright)} +.tab.active{background:var(--accent); color:#0a0d13; border-color:var(--accent); + box-shadow:0 2px 10px #e8a00033} +/* Mute-Button: klein, ganz rechts in der Tab-Leiste */ +.mute-tab{margin-left:auto;padding:7px 10px;font-size:14px;line-height:1} +.mute-tab.muted{opacity:.55} + +.view.hidden{display:none} +.view-head{display:flex;align-items:center;justify-content:space-between; + padding:10px 12px 4px} +.view-head h2{margin:0;font-size:13px;letter-spacing:1px;color:var(--bright)} +.reload{background:var(--card);border:1px solid var(--border);color:var(--bright); + border-radius:var(--r);width:34px;height:30px;font-size:15px;cursor:pointer; + transition:background .15s,border-color .15s} +.reload:hover{border-color:var(--dim)} + +/* ── Cards-Grid (Dashboard) ─────────────── */ +main{display:grid;grid-template-columns:1fr 1fr;gap:11px;padding:11px} +.card{background:var(--card);border:1px solid var(--border); + border-radius:var(--r-lg);padding:12px 14px;box-shadow:var(--shadow-sm)} +.card.wide{grid-column:1 / -1} +.card h2{margin:0 0 9px;font-size:10px;letter-spacing:1.5px; + text-transform:uppercase;color:var(--dim);font-weight:600} + +.kv{display:flex;justify-content:space-between;align-items:baseline; + padding:2px 0;font-size:12px} +.kv span{color:var(--dim)} +.kv b{font-weight:600} + +/* ── Aktueller Trade (Leiste über der Gesamtempfehlung) ───────────── */ +.trade-bar{padding:8px 12px;border-color:#2d4a2d} +.trade-bar.hidden{display:none} +#card-trail.hidden{display:none} /* Trailing-Kachel ausgeblendet, 2026-07-22 */ +.tb-title{margin:0 0 8px;font-size:10px;letter-spacing:1.5px;text-transform:uppercase; + color:var(--dim);font-weight:600} +.tb-row{display:flex;flex-wrap:wrap;gap:10px} +/* EINHEITLICHES Feld-Design: Anzeige-Boxen (Richtung/Lots/Einsatz) und Eingabe- + Felder (SL/TP/Gewinn-Close/Notfall-Close) sehen identisch aus (Label über Box). */ +.tb-field{display:flex;flex-direction:column;gap:2px} +.tb-field label{font-size:9px;color:var(--dim);letter-spacing:.5px} +.tb-field input, .tb-field .tb-val{width:88px;background:var(--surface-2); + border:1px solid var(--border);border-radius:var(--r-sm);color:var(--bright); + font-size:13px;font-weight:700;padding:6px 7px;text-align:center; + transition:border-color .15s, box-shadow .15s} +.tb-field .tb-val{display:block} /* nur Anzeige, kein Input */ +.tb-field input:focus{outline:none;border-color:var(--blue); + box-shadow:0 0 0 2px #58a6ff33} +.tb-field.stop input{border-color:#5c1a1a;color:#f85149} /* SL/Notfall rötlich */ +.tb-field.gain input{border-color:#1a4731;color:#3fb950} /* TP/Gewinn grünlich */ +.tb-field.stop input::placeholder,.tb-field.gain input::placeholder{color:#8b949e88} +.tb-dir.up{color:var(--up)} .tb-dir.down{color:var(--down)} +/* Auto-Close-Schalter als Box im Feld-Design: grau=AUS, grün=AN */ +.tb-btn{cursor:pointer;color:var(--dim)} +.tb-btn.on{background:#0d2a16;border-color:#1a4731;color:#3fb950} +.tb-btn:active{filter:brightness(1.3)} + +/* ── Gesamtempfehlung ───────────────────── */ +#card-verdict{border-color:var(--accent)} +.vd-head{display:flex;align-items:center;gap:14px;margin-bottom:10px} +.vd-ring{width:60px;height:60px;border-radius:50%;flex:0 0 auto; + display:flex;align-items:center;justify-content:center; + background:conic-gradient(var(--dim) 0deg, var(--line) 0deg)} +.vd-ring span{width:46px;height:46px;border-radius:50%;background:var(--card); + display:flex;align-items:center;justify-content:center; + font-size:14px;font-weight:700} +.vd-main{flex:1;min-width:0} +.vd-signal{font-size:26px;font-weight:800;color:var(--dim);line-height:1.1} +.vd-signal.long{color:var(--up)} .vd-signal.short{color:var(--down)} +.vd-consensus{font-size:11px;color:var(--dim);margin-top:3px} +.vd-meter{display:flex;align-items:center;gap:8px;margin:4px 0 12px} +.vd-end{font-size:9px;font-weight:700;letter-spacing:1px} +.vd-end.short{color:var(--down)} .vd-end.long{color:var(--up)} +.vd-track{position:relative;flex:1;height:8px;border-radius:5px; + background:linear-gradient(90deg,#f85149,var(--border) 50%,#3fb950)} +.vd-needle{position:absolute;top:-3px;left:50%;width:4px;height:14px; + border-radius:2px;background:#fff;box-shadow:0 0 5px #000; + transform:translateX(-50%);transition:left .3s} +.vd-modules{display:grid;grid-template-columns:repeat(auto-fit,minmax(94px,1fr));gap:6px} +.vd-mod{background:var(--bg);border:1px solid var(--border);border-radius:7px; + padding:6px 7px} +.vd-mod .m-top{display:flex;justify-content:space-between;align-items:center;gap:4px} +.vd-mod .m-name{font-size:10px;color:var(--dim);font-weight:600} +.vd-mod .m-vote{font-size:13px;font-weight:800;color:var(--dim)} +.vd-mod .m-det{font-size:9px;color:var(--dim);margin-top:2px; + white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.vd-mod.long {border-color:#1a6e35} .vd-mod.long .m-vote{color:var(--up)} +.vd-mod.short{border-color:#8b1a1a} .vd-mod.short .m-vote{color:var(--down)} + +/* ── Meldungen (EINHEITLICHES Design: Box + farbiger Links-Akzent) ─────────── */ +#card-pos .squeeze-ind, #card-pos .bounce-ind, #card-pos .cur-near, +#card-pos .pos-srhint, #card-pos .reasons{ + display:block; margin:0 0 7px; padding:8px 11px; border-radius:var(--r-sm); + background:var(--surface-2); border:1px solid var(--border); + border-left:3px solid var(--line); + font-size:12px; line-height:1.4; font-weight:600; letter-spacing:0; box-shadow:none; +} +/* leere Meldungen ausblenden (cur-near/srhint/reasons ohne Inhalt) */ +#card-pos .cur-near:empty, #card-pos .pos-srhint:empty, +#card-pos .pos-srhint.hidden, #card-pos .reasons:empty{display:none} +/* Farbvarianten — Links-Akzent + Textfarbe (Fläche bleibt einheitlich) */ +#card-pos .squeeze-ind.idle, #card-pos .bounce-ind.idle, +#card-pos .reasons{color:var(--dim)} +#card-pos .bounce-ind.warn, #card-pos .pos-srhint.warn{ + color:var(--accent); border-left-color:var(--accent)} +#card-pos .squeeze-ind.armed, #card-pos .pos-srhint.approach{ + color:var(--blue); border-left-color:#1f6feb} +#card-pos .squeeze-ind.fire.long, #card-pos .pos-srhint.run, +#card-pos .cur-near.near-sup{color:var(--up); border-left-color:var(--up)} +#card-pos .squeeze-ind.fire.short, #card-pos .cur-near.near-res{ + color:var(--down); border-left-color:var(--down)} +/* Setup/Lauf-Zeilen (Metadaten) fügen sich ein */ +#card-pos .kv{padding:3px 0} +/* Header-KURS-Box färbt sich nahe S/R (rot=Widerstand, grün=Support) */ +.px span.near-res{color:var(--down)} .px span.near-sup{color:var(--up)} + +/* ── Signal ─────────────────────────────── */ +.signal{font-size:18px;font-weight:700;color:var(--dim);margin-bottom:6px} +.signal.long{color:var(--up)} .signal.short{color:var(--down)} +.reasons{font-size:11px;color:var(--dim);margin-top:6px} +.ell-note{font-size:11px;color:var(--dim);margin-top:6px} + +/* ── Agent ──────────────────────────────── */ +.agent-head{font-weight:600;font-size:13px;margin-bottom:4px} +.agent-text{font-size:12px;color:var(--bright)} +.risks{margin:8px 0 0;padding-left:16px;font-size:11px;color:var(--accent)} +.risks li{margin:2px 0} + +/* ── Trades ─────────────────────────────── */ +.trades{display:grid;grid-template-columns:repeat(5,1fr);gap:6px} +.trade{background:var(--bg);border:1px solid var(--border); + border-radius:6px;padding:6px 3px;text-align:center} +.trade .p{font-size:12px;font-weight:700} +.trade .i{font-size:9px;color:var(--dim);margin-top:2px} + +.up{color:var(--up)} .down{color:var(--down)} .dim{color:var(--dim)} + +@media(max-width:360px){ + main{grid-template-columns:1fr} + .card:not(.wide){grid-column:1 / -1} +} + +/* ── Aktionsleiste ──────────────────────── */ +main{padding-bottom:74px} /* Platz für die fixe Leiste */ +#actions{ + position:fixed; left:0; right:0; bottom:0; z-index:8; + display:flex; gap:7px; + padding:9px 9px calc(9px + env(safe-area-inset-bottom)); + background:rgba(10,13,19,.86); border-top:1px solid var(--border); + backdrop-filter:blur(10px); -webkit-backdrop-filter:blur(10px); +} +.btn{ + flex:1; border:1px solid var(--border); border-radius:var(--r); + padding:12px 4px; font-size:13px; font-weight:700; color:var(--bright); + background:#1a1e24; font-family:inherit; cursor:pointer; + transition:background .12s, opacity .12s, transform .06s; touch-action:manipulation; +} +.btn:active{transform:translateY(1px)} +.btn.long {background:#1a6e35} +.btn.short {background:#8b1a1a} +.btn.close {background:#2d2000; color:var(--accent)} +.btn.tog {flex:.8; font-size:12px} +.btn.tog.on{background:#1f6feb; color:#fff} +.btn:disabled{opacity:.32; cursor:default} + +/* Empfehlungs-Blinken der LONG/SHORT-Buttons */ +@keyframes blkLong {0%,100%{background:#1a6e35}50%{background:#22c55e}} +@keyframes blkShort {0%,100%{background:#8b1a1a}50%{background:#ef4444}} +.btn.long.blink {animation:blkLong .7s ease-in-out infinite} +.btn.short.blink {animation:blkShort .7s ease-in-out infinite} +/* CLOSE blinkt, wenn das Signal gegen die offene Position dreht (Auto-Flip) */ +@keyframes blkClose {0%,100%{background:#2d2000;color:var(--accent)}50%{background:#e8a000;color:#000}} +.btn.close.blink {animation:blkClose .6s ease-in-out infinite} + +/* (Überdehnungs- & Squeeze-Anzeige nutzen jetzt das einheitliche Meldungs-Design + oben in der Position/Meldungen-Sektion — nur noch Farbvarianten je Zustand.) */ + +/* Live-Statistik (kompakt) */ +.stx-row {display:flex;flex-direction:column;gap:8px} +.stx-grp {background:var(--card);border:1px solid var(--border);border-radius:8px;padding:8px 10px} +.stx-lbl {font-size:11px;color:var(--text-dim);margin-bottom:5px;letter-spacing:.3px} +.stx-cells {display:flex;flex-wrap:wrap;gap:6px 14px} +.stx-c {font-size:12px;color:var(--text-dim)} +.stx-c b {font-size:14px;color:var(--text-bright);margin-left:3px} + +/* Charts-Tab: M1/M5/M15/M30/H1 untereinander (Lightweight Charts) */ +#charts-body {display:flex;flex-direction:column;gap:12px;padding:10px} +.chart-card {background:var(--surface-2);border:1px solid var(--border);border-radius:10px;overflow:hidden} +.chart-head {display:flex;align-items:center;gap:10px;padding:8px 12px;border-bottom:1px solid var(--line)} +.chart-tf {font-weight:800;font-size:15px;color:var(--text-bright);letter-spacing:.5px} +.chart-trend {font-weight:700;font-size:13px} +.chart-trend.up {color:#3fb950} +.chart-trend.down {color:#f85149} +.chart-trend.flat {color:var(--dim)} +.chart-last {margin-left:auto;font-variant-numeric:tabular-nums;color:var(--text-bright);font-size:14px} +.chart-cv {height:200px;width:100%} + +/* Kurslücken (Gaps): offene Fill-Magnete */ +.gaps-list {display:flex;flex-direction:column;gap:6px} +.gap-row {display:flex;flex-wrap:wrap;gap:8px;align-items:baseline;font-size:13px; + padding:6px 9px;background:var(--card);border:1px solid var(--border);border-radius:7px} +.gap-zone {font-variant-numeric:tabular-nums;color:var(--text-bright);font-weight:600} +.gap-fill {color:var(--text-dim)} +.gap-date {margin-left:auto;font-size:11px} + + +/* TF-Ampel (Welle): Wende je M1/M5/M15/M30 via EMA12/50-Kreuzung */ +.tf-turns {display:flex;gap:6px;flex-wrap:wrap;margin:8px 0 2px} +.tf-chip {display:inline-flex;align-items:center;gap:4px;padding:3px 8px; + border-radius:7px;font-size:12px;background:var(--card);border:1px solid var(--border); + color:var(--text-dim)} +.tf-chip b {font-weight:600;font-size:11px;letter-spacing:.3px} +.tf-chip.up {color:#3fb950;border-color:#1a4731} +.tf-chip.down {color:#f85149;border-color:#3d1a1a} +.tf-chip.flat {color:var(--dim)} +.tf-turn {font-weight:700} +.tf-ago {font-size:10px;opacity:.7;background:var(--surface-2);border-radius:4px;padding:0 4px} +/* Frische Wende: statisch hervorgehoben (kein Blinken) */ +.tf-chip.fresh.up {background:#0d2a16;border-color:#3fb950;color:var(--bright)} +.tf-chip.fresh.down {background:#2a0d0d;border-color:#f85149;color:var(--bright)} + +/* ── Modal ──────────────────────────────── */ +.modal{ + position:fixed; inset:0; z-index:20; display:flex; + align-items:center; justify-content:center; + background:#000000aa; padding:20px; +} +.modal.hidden,.toast.hidden{display:none} +.box{ + background:var(--card); border:1px solid var(--accent); + border-radius:14px; padding:18px; width:100%; max-width:340px; +} +.box-title{font-size:15px;font-weight:700;margin-bottom:8px} +.box-msg{font-size:13px;color:var(--bright);margin-bottom:14px;white-space:pre-line} +.box-msg.danger{color:var(--down);font-weight:600} +#modal-input.hidden{display:none} /* nur im Token-Modus sichtbar */ +#modal-input{ + width:100%; padding:11px; margin-bottom:14px; font-size:15px; + background:var(--bg); border:1px solid var(--border); border-radius:8px; + color:var(--bright); font-family:inherit; +} +.box-btns{display:flex;gap:10px} +.mbtn{ + flex:1; padding:12px; border:none; border-radius:9px; font-size:14px; + font-weight:700; font-family:inherit; cursor:pointer; +} +.mbtn.ok{background:var(--accent);color:#000} +.mbtn.cancel{background:#1a1e24;color:var(--bright);border:1px solid var(--border)} + +/* ── Logs ───────────────────────────────── */ +.logbox{ + margin:0 10px 10px; padding:10px; background:#000; border:1px solid var(--border); + border-radius:8px; font-size:10px; line-height:1.45; color:#c9d1d9; + white-space:pre-wrap; word-break:break-word; max-height:70vh; overflow-y:auto; +} + +/* ── Statistik ──────────────────────────── */ +#stats-body{padding:0 10px 12px} +.stat-card{background:var(--card);border:1px solid var(--border);border-radius:10px; + padding:10px 12px;margin-bottom:10px} +.stat-card h3{margin:0 0 8px;font-size:11px;letter-spacing:1px;color:var(--accent); + text-transform:uppercase} +.stat-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px 14px} +.stat-grid .kv{font-size:12px} +.stat-grid .kv.net span{color:var(--accent)} +.sub-h{margin:14px 12px 6px;font-size:11px;letter-spacing:1px; + color:var(--dim);text-transform:uppercase} +.setup-hint{padding:0 12px 6px;font-size:10px} +.setup-row{display:grid;grid-template-columns:1.4fr .9fr 1fr .75fr;gap:6px; + align-items:baseline;padding:7px 12px;border-bottom:1px solid var(--border)} +.setup-name{font-size:11px;color:var(--bright);word-break:break-word} +.setup-stat{font-size:11px;text-align:right} + +/* ── News ───────────────────────────────── */ +.news-sent{margin:4px 10px 8px;font-size:12px;color:var(--dim)} +#news-body{padding:0 10px 12px} +.news-item{display:block;background:var(--card);border:1px solid var(--border); + border-radius:8px;padding:8px 10px;margin-bottom:8px;text-decoration:none} +.news-item .t{font-size:12px;color:var(--bright);line-height:1.35} +.news-item .s{font-size:9px;color:var(--dim);margin-top:3px} + +/* ── Toast ──────────────────────────────── */ +.toast{ + position:fixed; left:50%; bottom:84px; transform:translateX(-50%); + z-index:25; background:var(--card); border:1px solid var(--border); + border-radius:10px; padding:10px 16px; font-size:13px; max-width:90%; + box-shadow:0 6px 24px #000a; +} +.toast.ok{border-color:var(--up)} .toast.err{border-color:var(--down)} + +/* ── Marktstruktur-Karte (M30, reine Anzeige) ───────────────────────── */ +#card-structure h2 .ms-tf{ font-size:11px; color:var(--dim); font-weight:600; + border:1px solid var(--border); border-radius:var(--r-sm); padding:1px 6px; margin-left:6px; } +.ms-top{ display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin:8px 0; } +.ms-trend{ font-weight:700; font-size:15px; } +.ms-trend.up{ color:var(--up); } .ms-trend.down{ color:var(--down); } .ms-trend.dim{ color:var(--dim); } +.ms-swings{ display:flex; align-items:center; gap:2px; flex-wrap:wrap; } +.ms-chip{ font:600 12px/1 var(--mono); padding:3px 6px; border-radius:var(--r-sm); + background:var(--surface-2); border:1px solid var(--border); } +.ms-chip.up{ color:var(--up); border-color:#2ea04333; } +.ms-chip.down{ color:var(--down); border-color:#f8514933; } +.ms-chip.dim{ color:var(--dim); } +.ms-arrow{ color:var(--dim); font-size:11px; } +.ms-bos{ display:flex; align-items:center; gap:8px; font-size:13px; margin:6px 0; } +.ms-bos.hidden{ display:none; } +.ms-bos-lbl{ font:600 11px/1 var(--mono); color:var(--dim); + border:1px solid var(--border); border-radius:var(--r-sm); padding:2px 6px; } +.ms-bos .up{ color:var(--up); } .ms-bos .down{ color:var(--down); } +.ms-chan{ margin-top:8px; } +.ms-chan-head{ display:flex; justify-content:space-between; align-items:baseline; font-size:13px; } +.ms-chan-head .up{ color:var(--up); font-weight:700; } +.ms-chan-head .down{ color:var(--down); font-weight:700; } +.ms-chan-head .dim{ color:var(--dim); font-weight:700; } +.ms-chan-note{ color:var(--dim); font-size:12px; } +.ms-chan-track{ position:relative; height:8px; margin:6px 0 3px; + background:var(--surface-2); border:1px solid var(--border); border-radius:99px; overflow:visible; } +.ms-chan-fill{ height:100%; border-radius:99px 0 0 99px; + background:linear-gradient(90deg,#1f6feb55,#58a6ff88); transition:width .4s; } +.ms-chan-dot{ position:absolute; top:50%; width:12px; height:12px; margin-left:-6px; + transform:translateY(-50%); border-radius:50%; background:var(--bright); + border:2px solid var(--card); box-shadow:0 0 0 1px var(--border); transition:left .4s; } +.ms-chan-scale{ display:flex; justify-content:space-between; font-size:10px; color:var(--dim); } + +/* ── Auto-Squeeze-Monitor (B4, Statistik-Tab) ───────────────────────── */ +.sqm-head{ display:flex; align-items:center; gap:10px; margin:4px 0 8px; } +.sqm-badge{ font:600 12px/1 var(--mono); padding:3px 8px; border-radius:var(--r-sm); + background:var(--surface-2); border:1px solid var(--border); } +.sqm-badge.up{ color:var(--up); border-color:#2ea04355; } +.sqm-badge.down{ color:var(--down); border-color:#f8514955; } +.sqm-badge.dim{ color:var(--dim); } +.sqm-grid{ display:grid; grid-template-columns:1fr 1fr; gap:10px; } +@media(max-width:560px){ .sqm-grid{ grid-template-columns:1fr; } } +.sqm-col{ background:var(--card); border:1px solid var(--border); border-radius:var(--r); + padding:10px 12px; } +.sqm-h{ font-size:12px; color:var(--dim); font-weight:600; margin-bottom:6px; } +.sqm-row{ display:flex; justify-content:space-between; align-items:baseline; + font-size:13px; padding:2px 0; border-bottom:1px solid var(--line); } +.sqm-row:last-child{ border-bottom:none; } +.sqm-row b{ font-family:var(--mono); } +.sqm-row b.up{ color:var(--up); } .sqm-row b.down{ color:var(--down); } +.sqm-exp{ font-size:11px; color:var(--dim); margin-top:6px; } +.sqm-note{ font-size:12px; color:var(--dim); margin-top:8px; line-height:1.4; } diff --git a/weekly_review.py b/weekly_review.py new file mode 100644 index 0000000..dabbae9 --- /dev/null +++ b/weekly_review.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""B4-Wochenreport (Strategie-Lebenszyklus, s. CLAUDE.md Track B). +Hält die LIVE-Zahlen (trades-DB) gegen die BACKTEST-Erwartung und flaggt die bekannten +Leckagen (Sizing-Asymmetrie, Gegen-Signal-Trades, Winner-Kappen, Regime-Verfall). +Aufruf: python weekly_review.py (letzte 6 Wochen + diese Woche im Detail) +Zeitspalten der DB sind LOKALE Epoch (Broker-Offset bereits korrigiert). +""" +import sqlite3, datetime as dt +DB = "oil_widget_history.db" + +# ── Backtest-Erwartung (R-basiert, exit-sim; s. CLAUDE.md / backtest_*.py) ── +EXP_WR = 0.68 # Treffer ~68–71 % (Reversal/Trend, SL2+Trail) +EXP_PF = 1.30 # Profit-Faktor in R +EXP_VERH = 0.55 # Ø-Gewinn/Ø-Verlust in R (WR hoch → Wins kleiner, trotzdem PF>1) + +def net(r): return (r["pnl"] or 0.0) + (r["commission"] or 0.0) +def d_of(r): return "LONG" if r["direction"] in ("BUY","LONG","buy") else "SHORT" +def align(r): + rs = (r["rec_signal"] or "WARTEN").upper() + if rs in ("LONG","SHORT"): return "mit" if rs == d_of(r) else "gegen" + return "ohne" + +def agg(rows): + v = [net(r) for r in rows] + if not v: return None + n = len(v); wins = [x for x in v if x > 0]; loss = [x for x in v if x < 0] + w = len(wins); g = sum(wins); ls = -sum(loss) + aw = g / w if w else 0.0; al = ls / len(loss) if loss else 0.0 + return dict(n=n, wr=w/n, pf=(g/ls if ls > 0 else 99.9), sum=sum(v), + aw=aw, al=al, verh=(aw/al if al > 0 else 99.9), worst=min(v)) + +def line(lbl, s): + if not s: return f" {lbl:<14} —" + return (f" {lbl:<14} n={s['n']:>3} WR={100*s['wr']:>3.0f}% PF={s['pf']:>4.2f} " + f"Verh={s['verh']:>4.2f} Øgew={s['aw']:>+5.2f} Øverl={s['al']:>+6.2f} " + f"Σ={s['sum']:>+8.2f} Worst={s['worst']:>+7.2f}") + +def _squeeze_section(m5) -> list: + """B4-Monitor für den Volatilitäts-Squeeze-Breakout: rechnet die Regel mit den + LIVE-Params auf den letzten ~30 Tagen M5 NEU (der Setup ist mechanisch → seine + Live-Performance = Neuberechnung auf frischen Bars, keine Trade-Attribution nötig) + und hält ØR/PF gegen die Backtest-Erwartung (+0,14…+0,23). m5 = {H,L,C,SP} oder None.""" + if not m5 or len(m5.get("C") or []) < 300: + return ["", "SQUEEZE-BREAKOUT (B4): keine M5-Daten — übersprungen."] + try: + import backtest_breakout_squeeze as BQ + from core.wave_rec import _SQ_N, _SQ_MULT, _SQ_K + BQ._N = _SQ_N; BQ._K = _SQ_K # live-Params in den Scanner + H, L, C, SP = m5["H"], m5["L"], m5["C"], m5["SP"] + A = BQ._atr_series(H, L, C) + sq, _ = BQ.scan(H, L, C, A, SP, 1.0, 0, len(C), _SQ_MULT) # SP schon in Preis + out = ["", "-" * 92, + f"SQUEEZE-BREAKOUT (B4-Monitor · ~30 Tage M5 neu gerechnet · live-Params " + f"Box={_SQ_N}/k={_SQ_K}/≤{_SQ_MULT}×ATR):"] + n = len(sq) + if n == 0: + out.append(" keine Squeeze-Ausbrüche im Fenster — weiter beobachten.") + return out + w = sum(1 for x in sq if x > 0); s = sum(sq) + up = sum(x for x in sq if x > 0); dn = -sum(x for x in sq if x < 0) + pf = up / dn if dn > 0 else 99.9; oR = s / n + out.append(f" n={n} Treffer={100*w/n:.0f}% ØR={oR:+.3f} PF={pf:.2f} ΣR={s:+.0f}" + f" (Erwartung ØR +0,14…+0,23 · PF>1)") + if oR <= 0 or pf < 1.0: + out.append(" (!) ØR≤0/PF<1 im Fenster → Edge driftet? B5 prüfen (Regimewechsel). " + "⚠ kleine Stichprobe, nicht überreagieren.") + elif n < 6: + out.append(" ⚠ noch wenige Trades — nicht aussagekräftig, weiter beobachten.") + else: + out.append(" [OK] im Rahmen der Erwartung — Setup trägt live.") + return out + except Exception as e: + return ["", f"SQUEEZE-BREAKOUT (B4): Fehler ({e})"] + + +def _fetch_m5(days=30): + """M5-Bars für den Squeeze-Monitor (nur STANDALONE — schließt MT5 nur, wenn WIR + es initialisiert haben; die Server-Instanz reicht die Bars direkt in build_report).""" + try: + import MetaTrader5 as mt5 + pre = mt5.terminal_info() is not None # schon vom Server initialisiert? + if not pre and not mt5.initialize(): + return None + try: + sym = next((c for c in ("SpotCrude", "USOIL", "WTI", "XTIUSD") + if mt5.symbol_info(c)), None) + if not sym: + return None + bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, days * 288 + 30) + point = mt5.symbol_info(sym).point + finally: + if not pre: + mt5.shutdown() + if bars is None: + return None + return {"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]} + except Exception: + return None + + +def build_report(db=DB, m5=None) -> str: + """Baut den B4-Wochenreport als reinen Text (für Konsole, Mail-
      , Telegram).
      +    m5 = vorab geholte M5-Bars für den Squeeze-Monitor (Server reicht sie unter
      +    mt5_lock durch); None → Squeeze-Abschnitt versucht selbst zu laden (standalone)."""
      +    if m5 is None:
      +        m5 = _fetch_m5()
      +    out = []; P = out.append
      +    c = sqlite3.connect(db); c.row_factory = sqlite3.Row
      +    rows = [r for r in c.execute(
      +        "SELECT * FROM trades WHERE exit_time IS NOT NULL ORDER BY entry_time")]
      +    now = dt.datetime.now()
      +    def wkey(ts): return dt.datetime.fromtimestamp(ts).isocalendar()[:2]  # (jahr,kw)
      +    thisk = now.isocalendar()[:2]
      +
      +    P("=" * 92)
      +    P(f"  B4-WOCHENREPORT  ·  {now:%d.%m.%Y %H:%M}  ·  Live (EUR netto) vs Backtest-Erwartung")
      +    P(f"  Erwartung: WR~{100*EXP_WR:.0f}% · PF~{EXP_PF:.2f} · Verhältnis~{EXP_VERH:.2f} (R-basiert)")
      +    P("=" * 92)
      +
      +    from collections import OrderedDict
      +    byw = OrderedDict()
      +    for r in rows: byw.setdefault(wkey(r["entry_time"]), []).append(r)
      +    P("\nWochenverlauf (Verfall-Check):")
      +    for (yr, kw), rs in list(byw.items())[-6:]:
      +        tag = "  <- diese Woche" if (yr, kw) == thisk else ""
      +        P(line(f"KW{kw}/{yr%100}", agg(rs)) + tag)
      +
      +    tw = byw.get(thisk, [])
      +    P(f"\nDiese Woche — nach Empfehlung (n={len(tw)}):")
      +    for k in ("mit", "gegen", "ohne"):
      +        P(line(k, agg([r for r in tw if align(r) == k])))
      +
      +    P("\nGesamt (alle Trades):")
      +    P(line("alle", agg(rows)))
      +
      +    recent = [r for (yr, kw), rs in list(byw.items())[-4:] for r in rs]
      +    s = agg(recent)
      +    P("\n" + "-" * 92)
      +    P(f"BEWERTUNG (letzte 4 Wochen, n={s['n'] if s else 0}):")
      +    if s:
      +        def mark(ok): return "[OK]" if ok else "[!]"
      +        P(f"  {mark(s['wr']>=EXP_WR-0.08)} Trefferquote  {100*s['wr']:.0f}%  (erwartet ~{100*EXP_WR:.0f}%)")
      +        P(f"  {mark(s['pf']>=1.0)} Profit-Faktor {s['pf']:.2f}  (erwartet ~{EXP_PF:.2f}; <1 = verlustbringend)")
      +        P(f"  {mark(s['verh']>=EXP_VERH-0.10)} Verhältnis    {s['verh']:.2f}  (erwartet ~{EXP_VERH:.2f})")
      +        flags = []
      +        gg = agg([r for r in recent if align(r) == "gegen"])
      +        share_gg = (gg["n"]/s["n"]) if gg else 0
      +        if share_gg > 0.12:
      +            flags.append(f"(!) {100*share_gg:.0f}% GEGEN-Signal-Trades (dort WR ~14 % — meiden!)")
      +        if s["verh"] < EXP_VERH - 0.10:
      +            flags.append("(!) Verhältnis unter Erwartung → Gewinner zu klein (Kappen) oder Verlierer zu groß (Sizing 90 %)")
      +        if s["wr"] < EXP_WR - 0.10:
      +            flags.append("(!) Treffer unter Erwartung → Regime-Verfall ODER zu viele Nicht-Setup-Trades")
      +        if s["pf"] < 1.0:
      +            flags.append("(!) PF<1 = Verlustphase → B5 prüfen: Regimewechsel? Sizing? Disziplin?")
      +        if s["worst"] < -25:
      +            flags.append(f"(!) Einzelverlust {s['worst']:+.0f} € — 90 %-Margin-Sizing (Memory: risk_pct=1.5)")
      +        P("\n  " + ("\n  ".join(flags) if flags else "[OK] keine Leckage-Flags — im Rahmen der Erwartung."))
      +    P("-" * 92)
      +    P("Nächster Schritt bei [!]: B5 (behalten/verwerfen) — driftet der Live-Edge weg,")
      +    P("erst Ursache trennen (Regime vs. Disziplin vs. Sizing), nicht am Signal drehen.")
      +    for ln in _squeeze_section(m5):
      +        P(ln)
      +    return "\n".join(out)
      +
      +def main():
      +    print(build_report())
      +
      +if __name__ == "__main__":
      +    main()