From 874ee16e00bd256fc8623007ed814c9c4ac882ef Mon Sep 17 00:00:00 2001 From: Axel Hocks Date: Thu, 6 Aug 2026 23:21:52 +0200 Subject: [PATCH] Gesamtempfehlung: Selbst-Kalibrierung, voller Ausrichtungs-Split, Block-Gruende, Elliott entmachtet (v=144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vier Verbesserungen, alle aus dem Gemessenen abgeleitet — kein neues Signal. Ausgangspunkt: die Karte hat genau eine belegte Funktion, das Veto; als Prognose ist sie ein Muenzwurf (48-51 % ueber 1.071 Episoden). (1) SELBST-KALIBRIERUNG. Neue Tabelle rec_outcomes: eine Zeile je EPISODE (Richtungswechsel, nicht je Minute), Auswertung ~alle 5 min gegen candles_m1 nach 30/60 min, Snapshot rec_track, Zeile #vd-selfcal. Gegen 50 % zu lesen. Warum der groesste Hebel: die Qualitaet war bis heute unsichtbar und musste auf Nachfrage rueckwirkend gemessen werden. Dasselbe Muster (pbreak_predictions) hat den Ausfall des P(break)-Modells LIVE aufgedeckt. Mit 4 Szenarien getestet (tests_rec_outcomes.py). Zwei Fehlschlaege dabei waren Testfehler, keine Code-Fehler — dokumentiert. (2) alignment_stats auf die VOLLE Historie (1.216 statt 40 Trades) und zusaetzlich je Lot. Absolute Euro-Vergleiche sind bei wechselnder Positionsgroesse ungueltig (Lehre 04.08.). Damit steht die einzige beidhaelftig robuste Aussage der Karte auf ihrer vollen Stichprobe. (3) Block-Gruende sichtbar (block_mix -> #vd-blocks): "heute X % ohne Block · entry_room … · min_conf …". Bisher nur per DB-Abfrage zu beantworten. (4) ELLIOTT: Verdict-Gewicht 1,0 -> 0. Gemessen ueber 15.544 Zeilen hatte es mit 1.384 Episoden die groesste Stichprobe der Tabelle und ist darin flach (-0,075/+0,032, 50 % Treffer) — bei 25,9 % Einfluss auf die Nadel, weil es in nur 1 % der Zeilen schweigt. Chip bleibt, Stimme entfaellt. ⚠ Folge: die Nadel schlaegt staerker aus (real +0,33 -> +1,00). ⚠ Namenskollision beim Bau gefunden und behoben: die neue Klasse hiess zuerst .vd-track — so heisst bereits die Schiene der Bias-Nadel; sie waere ueberschrieben worden. Jetzt .vd-selfcal. Live verifiziert: alignment je Lot, block_mix (93 von 1381 ohne Block), rec_outcomes angelegt, Elliott weight=0 bei erhaltenem Chip, v=144 ausgeliefert, eine Instanz auf Port 8000. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 55 ++++++++++- backtest_auto_signal_v3.py | 22 ++++- backtest_signal_touchfill.py | 117 ++++++++++++++++++++++++ core/engine.py | 171 +++++++++++++++++++++++++++++++++-- core/history.py | 144 +++++++++++++++++++++++------ tests_rec_outcomes.py | 163 +++++++++++++++++++++++++++++++++ web/app.js | 58 +++++++++++- web/index.html | 13 ++- web/style.css | 10 ++ 9 files changed, 705 insertions(+), 48 deletions(-) create mode 100644 backtest_signal_touchfill.py create mode 100644 tests_rec_outcomes.py diff --git a/CLAUDE.md b/CLAUDE.md index a4eae9d..6761aa3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3608,7 +3608,60 @@ dort bereits nachvalidiert, ØR +0,305.) **Eine** Oberfläche: Anzeige zu einer Erwartung ein, die die Messung nicht deckt. ⚠ Bewusst **statisches Markup, kein JS** — es ist ein Dauerhinweis, kein Zustand; damit entfällt auch jedes Null-Risiko beim Render (s. Cache-Regel oben). -- Asset-Version aktuell **v=143** (in `web/index.html` hochzählen, siehe Workflows). +- **⚠⚠ VIER VERBESSERUNGEN AN DER GESAMTEMPFEHLUNG (User-Vorgabe 2026-08-06, + v=144) — alle aus dem Gemessenen abgeleitet, KEIN neues Signal.** Ausgangspunkt: + die Karte hat genau eine belegte Funktion, das **Veto** (s. Ausrichtungs-Split); + als Prognose ist sie ein Münzwurf. Verbessert wird also das Veto und die + Sichtbarkeit — nicht die Vorhersage. + **(1) SELBST-KALIBRIERUNG — die Karte zeigt ihre eigene Trefferquote.** + Neue Tabelle **`rec_outcomes`** (DB-Backup `…bak-2026-08-06-recoutcomes`): + `history.log_rec_outcome` schreibt **eine Zeile je EPISODE** (Richtungswechsel, + nicht je Minute — sonst zählt man dieselbe Marktlage dutzendfach); + `engine._evaluate_rec_outcomes` trägt ~alle 5 min im Trend-Loop den + Forward-Return nach 30/60 min aus `candles_m1` nach (⚠ Broker-Offset, wie bei + `_evaluate_pbreak_predictions`). `history.rec_accuracy(200)` → Snapshot + **`rec_track`** → Zeile **`#vd-selfcal`**. + ⚠ **Warum das der grösste Hebel ist:** die Qualität der Empfehlung war bis heute + unsichtbar — sie musste auf Nachfrage rückwirkend gemessen werden. Dasselbe + Muster (`pbreak_predictions`) hat den Ausfall des P(break)-Modells LIVE + aufgedeckt, was kein Backtest konnte. Die Zeile ist **gegen 50 % zu lesen**, + nicht gegen 0. Fehlen Bars (Wochenende), bleibt die Zeile offen statt falsch + bewertet; nach 6 h wird sie geschlossen. **Mit 4 Szenarien getestet** + (`tests_rec_outcomes.py`): Episoden-Dedup · WARTEN re-armt · Auswertung inkl. + Offset in beide Richtungen · fehlende Bar → bleibt offen · Aggregation. + ⚠ Zwei Fehler dabei waren MEINE Testfehler, nicht Code-Fehler: die Test-Kerzen + aus dem Vorabschnitt lagen im Fenster des nächsten, und die DB-Kopie enthält + **echte** `candles_m1` — der Fall „keine Bar" entsteht erst, wenn das Fenster + wirklich geleert wird. + **(2) AUSRICHTUNGS-SPLIT auf die volle Historie + je Lot.** `alignment_stats` + lief auf den **letzten 40** Trades und zeigte absolute Euro. Jetzt über **alle + 1.216** (Default `last_n=0`) und zusätzlich **`pnl_lot`** — absolute + Euro-Vergleiche sind bei wechselnder Positionsgrösse ungültig (Lehre 04.08.). + Der Order-Dialog zeigt beides. Damit steht die einzige beidhälftig robuste + Aussage der Karte auf ihrer vollen Stichprobe: **gegen das Signal −6,60 €/Lot**. + **(3) BLOCK-GRUND SICHTBAR** (`engine._block_mix_cached` → Snapshot + `block_mix` → `#vd-blocks`): „heute X % ohne Block · entry_room … · min_conf …". + Auf die Frage „warum gab es so wenige Empfehlungen?" war bisher eine DB-Abfrage + nötig. ~120 s gecacht, reine Anzeige. + **(4) ELLIOTT ENTMACHTET — Verdict-Gewicht 1,0 → 0** (gemessen + `analyze_verdict_modules.py`, 15.544 Zeilen). Elliott hatte mit **1.384 + Episoden die grösste Stichprobe der ganzen Tabelle** und ist darin **flach**: + Überschuss −0,075/+0,032 ×ATR, **50 % Treffer**. Gleichzeitig war es mit + **25,9 % die zweitlauteste Stimme** — weil es in nur 1 % der Zeilen schweigt. + Mit H1 (38,1 %) bildete es einen Dauerhintergrund von ~64 %. **Der Chip bleibt** + (das Ziel ist als Kontext nützlich), nur die Stimme entfällt. + ⚠ **Sichtbare Folge: die Nadel schlägt stärker aus.** Real beim Umbau + +0,33 → **+1,00**, weil nur noch H1 (1,5) und Liq-Trend (0,5) sprachen. Mit + wenigen sprechenden Modulen kann ein einzelnes die Nadel auf ±1 ziehen — das ist + ehrlicher als eine künstlich gedämpfte Nadel, sieht aber dramatischer aus. + ⚠ **Reine Anzeige-Änderung** — die Order steuert die Welle über die Headline, + nicht der Konsens. Zurück: `ew = 1.0 if ev != 0 else 0.0` in `_verdict`. + ⚠ **Nicht angefasst, aber auffällig** (für die 25.000-Verdict-Messung): + **Orderbuch** ist über 2.688 Episoden beidhälftig NEGATIV (−0,054/−0,066, + 49 % Treffer) bei Gewicht 0,5 — der einzige konsistent falsche Beitrag. + **Muster** (+1,27/+0,67, 58 %, n=88) und **Liq-Trend** (+0,20/+0,32, n=132) + sehen gut aus, sind aber zu dünn. Alle übrigen kippen zwischen den Hälften. +- Asset-Version aktuell **v=144** (in `web/index.html` hochzählen, siehe Workflows). Schriftgrößen 2026-07-24 global **+4px** (2× je +2px auf User-Wunsch; Body-Basis 14→18px). ⚠ Betrifft in `style.css` sowohl `font-size:Npx` (71×) ALS AUCH die `font: Npx/…`-**Shorthand** (3×: `.ms-chip`/`.ms-bos-lbl`/`.sqm-badge` — diff --git a/backtest_auto_signal_v3.py b/backtest_auto_signal_v3.py index 1e6751f..c8d7faa 100644 --- a/backtest_auto_signal_v3.py +++ b/backtest_auto_signal_v3.py @@ -96,8 +96,18 @@ def _htf_sign(C, i, n, atr): def lauf(w, H, L, C, T, A, SP, EF, ES, AD, HTF, H1, PH, PL, lo, hi, - min_conf, nacht, am_level, slip=0.0): - """Ein Durchlauf: sequentiell, EIN Positions-Slot, Live-Dedup.""" + min_conf, nacht, am_level, slip=0.0, fill="close"): + """Ein Durchlauf: sequentiell, EIN Positions-Slot, Live-Dedup. + + `fill` (2026-08-06 ergaenzt, Default = bisheriges Verhalten, bitgenau): + "close" — die Bestaetigung gilt, wenn der Bar-CLOSE das Level bricht. + "touch" — die ruhende Stop-Order fuellt bei der ERSTEN BERUEHRUNG, auch + wenn der Bar wieder zurueckfaellt. Das ist, was live passiert. + ⚠ Der Unterschied ist kein Detail: beim Momentum betrug er 0,45 R und drehte + +0,40 auf −0,06 (`backtest_momentum_v2.py`), beim Squeeze 0,32 R + (`backtest_squeeze_touchfill.py`). "close" konditioniert auf den Bar-Schluss, + den eine liegende Order nicht kennen kann. + """ def kosten(i, atr): return (SP[i] if SP[i] > 0 else 0.0225) / atr @@ -135,7 +145,13 @@ def lauf(w, H, L, C, T, A, SP, EF, ES, AD, HTF, H1, PH, PL, lo, hi, pend = {"dir": d, "level": C[i] + d * _K * atr, "invalid": C[i] - d * _K * atr, "confirmed": False, "bar": i} if not pend["confirmed"]: - broke = (C[i] >= pend["level"]) if d > 0 else (C[i] <= pend["level"]) + if fill == "touch": + # Beruehrung genuegt — die Order liegt dort und wird ausgeloest. + broke = ((H[i] >= pend["level"]) if d > 0 + else (L[i] <= pend["level"])) + else: + broke = ((C[i] >= pend["level"]) if d > 0 + else (C[i] <= pend["level"])) against = (C[i] <= pend["invalid"]) if d > 0 else (C[i] >= pend["invalid"]) if broke: pend["confirmed"] = True diff --git a/backtest_signal_touchfill.py b/backtest_signal_touchfill.py new file mode 100644 index 0000000..d6ea908 --- /dev/null +++ b/backtest_signal_touchfill.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Haelt der SIG-Pending-Pfad auch mit realistischem Fill? — die offene Zahl. + +ANLASS (2026-08-06): `signal_pending_entry` laeuft seit dem 05.08. mit ECHTEM +Geld. Die Erwartung dahinter (ØR +0,39/+0,36) stammt aus +`backtest_auto_signal_v3.py` und unterstellt: Einstieg zum Level-Preis, aber +**nur dort, wo der Bar-CLOSE das Level gebrochen hat**. Eine liegende Stop-Order +weiss das nicht — sie fuellt bei der ERSTEN BERUEHRUNG, auch bei jeder, die +wieder zurueckfaellt. + +Wie teuer dieser Unterschied ist, wurde heute zweimal gemessen: + Momentum +0,40 → −0,06 (0,45 R Aufschlag) — Level WANDERT + Squeeze +0,46 → +0,13 (0,32 R Aufschlag) — Level STEHT +Der Squeeze haelt, das Momentum nicht. **Und `_pend` gehoert zur wandernden +Sorte:** der Anker wird bei jeder k×ATR-Gegenbewegung, nach Timeout und bei +Richtungswechsel neu gesetzt. Es ist also mit dem schlechteren Fall zu rechnen. + +Benutzt dieselbe `lauf()` wie v3, nur mit `fill="touch"` — nichts nachgebaut, +damit die Zahlen vergleichbar bleiben. Der Default `fill="close"` reproduziert +v3 bitgenau (verifiziert). + +VORAB FIXIERTE ENTSCHEIDUNGSREGEL: + `signal_pending_entry` bleibt nur gerechtfertigt, wenn der Pfad mit + `fill="touch"` in BEIDEN Haelften ØR > 0 UND PF > 1 haelt UND besser ist als + der Markt-Einstieg. Sonst gehoert er abgeschaltet. + +Aufruf: python backtest_signal_touchfill.py [bars] +""" +import sys + +import MetaTrader5 as mt5 + +import backtest_auto_signal_v3 as V +from core.wave_rec import WaveRecommender, calc_trend_angle + + +def main(): + n_bars = 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_bars) + point = mt5.symbol_info(sym).point + mt5.shutdown() + # ⚠ Aufbau 1:1 wie in v3.main() — aber ueber die IMPORTIERTEN Helfer, nicht + # ueber Kopien. Waere hier ein eigener EMA/ATR drin, waere der Vergleich + # zwischen den beiden Fill-Annahmen nicht mehr sauber. + 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] + EF = V._ema_series(C, V._EMA_FAST); ES = V._ema_series(C, V._EMA_SLOW) + A = V._atr_series(H, L, C) + AD = [0.0] * len(C) + for i in range(V._N_BARS, len(C)): + AD[i] = calc_trend_angle(C[i - V._N_BARS:i + 1], V._ANGLE_LR) - 90.0 + if abs(AD[i]) < V._ANGLE_DEAD: + AD[i] = 0.0 + HTF = [V._htf_sign(C, i, 6, A[i] or 0.1) for i in range(len(C))] + H1S = [V._htf_sign(C, i, 12, A[i] or 0.1) for i in range(len(C))] + PH = [j for j in range(V._PIV_K, len(C) - V._PIV_K) + if H[j] == max(H[j - V._PIV_K:j + V._PIV_K + 1])] + PL = [j for j in range(V._PIV_K, len(C) - V._PIV_K) + if L[j] == min(L[j - V._PIV_K:j + V._PIV_K + 1])] + + w = WaveRecommender(lambda: None) + w.set_reversal_enabled(False) + w.set_entry_room(0.6) # ⚠ ohne das ist das Raum-Gate ein No-op + w.set_dead_hours("") + mid = len(C) // 2 + + print("=" * 104) + print(f" SIG-PENDING — Fill bei BAR-CLOSE gegen Fill bei BERUEHRUNG " + f"({sym}, {len(C)} M5-Bars)") + print("=" * 104) + print(f" {V._berlin(T[0]):%d.%m.%Y} bis {V._berlin(T[-1]):%d.%m.%Y} · " + f"voller Live-Gate-Stack · kanonischer Exit · Echtkosten · EIN Slot") + print() + + def paar(mc, am_level, fill): + r1, _ = V.lauf(w, H, L, C, T, A, SP, EF, ES, AD, HTF, H1S, PH, PL, + 0, mid, mc, V._NACHT_LIVE, am_level, 0.0, fill) + r2, _ = V.lauf(w, H, L, C, T, A, SP, EF, ES, AD, HTF, H1S, PH, PL, + mid, len(C), mc, V._NACHT_LIVE, am_level, 0.0, fill) + return V.kenn(r1), V.kenn(r2) + + print(f" {'Variante':<38}{'H1 n WR OeR PF SigmaR':>33}" + f" |{'H2 n WR OeR PF SigmaR':>33}") + print(" " + "-" * 102) + print(" A) MARKT am Bar-Close (der Ist-Zustand vor dem 05.08.)") + for mc in (55, 65, 75): + V.zeile(f" Konf>={mc}", *paar(mc, False, "close")) + print(" " + "-" * 102) + print(" B) am Level, Fill nur bei CLOSE-Bruch (die Zahl, auf der live steht)") + for mc in (55, 65, 75): + V.zeile(f" Konf>={mc}", *paar(mc, True, "close")) + print(" " + "-" * 102) + print(" C) am Level, Fill bei BERUEHRUNG (was live wirklich passiert)") + haelt = [] + for mc in (55, 65, 75): + k1, k2 = paar(mc, True, "touch") + if V.zeile(f" Konf>={mc}", k1, k2): + haelt.append(mc) + print() + print("=" * 104) + if haelt: + print(f" → Variante C haelt bei Konf {haelt} — `signal_pending_entry` ist gedeckt.") + else: + print(" → Variante C haelt in KEINER Konfidenz-Stufe.") + print(" Damit steht `signal_pending_entry=true` auf einer Zahl, die die") + print(" realistische Fill-Annahme nicht ueberlebt → abschalten.") + print(" ⚠ B gegen C gelesen = der Look-ahead-Aufschlag (Squeeze 0,32 R,") + print(" Momentum 0,45 R).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/core/engine.py b/core/engine.py index 0e3cf41..223965e 100644 --- a/core/engine.py +++ b/core/engine.py @@ -690,6 +690,8 @@ class TradingEngine: 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}") + try: self._evaluate_rec_outcomes() # ~alle 5 min, self-throttled + except Exception as re_: log.warning(f"rec_outcomes: {re_}") # Wirtschaftskalender (self-throttled ~1 h, außerhalb des mt5_lock # unkritisch — reiner HTTP-Fetch mit Timeout) try: self.calendar.maybe_refresh() @@ -1675,6 +1677,30 @@ class TradingEngine: except Exception as e: log.warning(f"log_recommendation: {e}") + # ── Selbst-Kalibrierung: eine Zeile je EPISODE (2026-08-06) ────────── + # WARUM: die Qualitaet der Empfehlung war bis heute unsichtbar — sie musste + # auf Nachfrage rueckwirkend gemessen werden (`analyze_signal_live.py`: + # 48–51 % Treffer ueber 1.071 Episoden). Dasselbe Muster wie + # `pbreak_predictions` hat den Ausfall des P(break)-Modells LIVE aufgedeckt, + # was kein Backtest konnte. Reine Messung, kein Eingriff in die Logik. + # ⚠ Nur bei RICHTUNGSWECHSEL, nicht je Minute — sonst zaehlt man dieselbe + # Marktlage dutzendfach (der Fehler, den die Episoden-Bildung behebt). + try: + _sig = rec.get("signal") + if _sig in ("LONG", "SHORT") and _sig != getattr(self, "_rec_epi_sig", None): + _atr = float((s.get("pb_feats") or {}).get("atr") or 0.0) + _px = float((self.data.snapshot() or {}).get("bid") or 0.0) + if _atr > 0 and _px > 0 and self.history: + self.history.log_rec_outcome( + signal=_sig, conf_pct=int(rec.get("conf_pct") or 0), + tf=str(rec.get("tf") or self.wave.timeframe_name or ""), + price=_px, atr=_atr) + if _sig in ("LONG", "SHORT", "WARTEN"): + # WARTEN re-armt: die naechste Richtung ist eine NEUE Episode. + self._rec_epi_sig = _sig if _sig != "WARTEN" else None + except Exception as e: + log.debug(f"log_rec_outcome: {e}") + # Verdict-Stimmen-Logging (2026-07-24, Idee 2b): Modul-Votes 1×/min in die # DB (`verdict_votes`), damit die UNBELEGTEN Stimmen (KI-Copilot, Elliott) # nach ein paar Wochen datenbasiert bewertet werden können (behalten / @@ -2856,11 +2882,20 @@ class TradingEngine: 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}" - # ⚠ Gewicht nur bei GERICHTETEM Ziel (2026-07-31, Modul-Audit): liegt das - # Ziel exakt auf dem Kurs, ist ev=0 — das ist keine Aussage und darf die - # Nadel nicht dämpfen. Betraf 1,3 % der Zeilen (klein, aber dieselbe Regel - # wie bei Welle/Squeeze/M30/H1/KI — Konsistenz statt Sonderfall). - ew = 1.0 if ev != 0 else 0.0 + # ⚠⚠ GEWICHT AUF 0 GESETZT (2026-08-06, gemessen + # `analyze_verdict_modules.py` über 15.544 Zeilen). Elliott hatte mit + # **1.384 Episoden die grösste Stichprobe** der ganzen Tabelle und ist + # darin **flach**: Überschuss −0,075 / +0,032 ×ATR, **50 % Treffer**. + # Gleichzeitig war es mit **25,9 % die zweitlauteste Stimme** der + # Bias-Nadel — weil es praktisch nie schweigt (stumm in 1 % der Zeilen). + # Zusammen mit H1 (38,1 %) bildete es einen Dauerhintergrund von ~64 %, + # gegen den die episodischen Module erst anschieben mussten. + # Ein Modul mit der grössten Stichprobe und ohne messbaren Inhalt darf + # den Konsens nicht dominieren. Der CHIP bleibt (reine Anzeige, das Ziel + # ist als Kontext nützlich) — nur die Stimme entfällt. + # ⚠ Reine ANZEIGE-Änderung: die Order steuert die Welle über die + # Headline, nicht der Konsens. Zurück: `ew = 1.0 if ev != 0 else 0.0`. + ew = 0.0 add("Elliott", ev, ew, edet) # 7) Squeeze-Breakout — Event-Signal: stimmt NUR bei `active` mit (dann 2,0, @@ -3037,20 +3072,72 @@ class TradingEngine: return getattr(self, "_manual_cache", None) def _alignment_cached(self) -> dict | None: - """Ausrichtungs-Split (mit/gegen/ohne Empfehlung, letzte 40 Trades) für den - Order-Bestätigungsdialog — ehrliche eigene Zahlen statt generischem Hinweis - (2026-07-24, Verhaltens-Hebel: Gegen-Signal-Trades sind die gemessene - Kern-Leckage). DB-Query gecacht (~120 s), Snapshot bleibt billig.""" + """Ausrichtungs-Split (mit/gegen/ohne Empfehlung) für den Order-Dialog — + ehrliche eigene Zahlen statt generischem Hinweis (2026-07-24; Gegen-Signal- + Trades sind die gemessene Kern-Leckage). + ⚠ Seit 2026-08-06 über die **volle Historie** (1.216 Trades) statt der + letzten 40 — der Befund ist dort beidhälftig robust, über 40 war er eine + Momentaufnahme. Zusätzlich je Lot normiert (`pnl_lot`), weil absolute + Euro-Vergleiche bei wechselnder Positionsgröße ungültig sind. + DB-Query gecacht (~120 s), Snapshot bleibt billig.""" now = time.time() if now - getattr(self, "_align_ts", 0.0) > 120: try: - self._align_cache = self.history.alignment_stats(40) + self._align_cache = self.history.alignment_stats(0) except Exception as e: log.debug(f"alignment_stats: {e}") self._align_cache = None self._align_ts = now return getattr(self, "_align_cache", None) + def _block_mix_cached(self) -> dict | None: + """WELCHE Gates blocken heute, und wie oft? (2026-08-06) + + WARUM SICHTBAR: auf die Frage „warum gab es heute so wenige Empfehlungen?" + liess sich das nur per DB-Abfrage beantworten. Real am 06.08.: von 646 + Zeilen waren **6 ungeblockt** — entry_room 274 · min_conf 225 · + breakout_pending 88 · htf_counter 50. Die Karte sagt das jetzt selbst. + Reine Anzeige, ~120 s gecacht.""" + now = time.time() + if now - getattr(self, "_blockmix_ts", 0.0) > 120: + try: + import sqlite3 + con = sqlite3.connect(HISTORY_DB_FILE, timeout=5.0) + rows = con.execute( + "SELECT COALESCE(block_reason,'') b, COUNT(*) n FROM recommendations " + "WHERE date(timestamp,'unixepoch','localtime') = date('now','localtime') " + "GROUP BY b ORDER BY n DESC").fetchall() + con.close() + ges = sum(r[1] for r in rows) + self._blockmix_cache = { + "total": ges, + "frei": next((r[1] for r in rows if not r[0]), 0), + "top": [{"grund": r[0], "n": r[1]} for r in rows if r[0]][:4], + } if ges else None + except Exception as e: + log.debug(f"block_mix: {e}") + self._blockmix_cache = None + self._blockmix_ts = now + return getattr(self, "_blockmix_cache", None) + + def _rec_track_cached(self) -> dict | None: + """Selbst-Kalibrierung: Trefferquote der letzten Empfehlungs-Episoden. + + ⚠ Gegen 50 % zu lesen, nicht gegen 0 — es ist eine Richtungsaussage. + Gemessener Ausgangsstand (`analyze_signal_live.py`, 1.071 Episoden): + 48–51 %. Diese Zeile macht laufend sichtbar, ob sich das aendert, statt + dass es erst auf Nachfrage rueckwirkend gemessen wird. ~120 s gecacht.""" + now = time.time() + if now - getattr(self, "_rectrack_ts", 0.0) > 120: + try: + self._rectrack_cache = (self.history.rec_accuracy(200) + if self.history else None) + except Exception as e: + log.debug(f"rec_accuracy: {e}") + self._rectrack_cache = None + self._rectrack_ts = now + return getattr(self, "_rectrack_cache", None) + def _loss_streak_today(self) -> tuple[int, int]: """(Verlust-Trades heute, Trades heute) — für den Mental-Check der Entry- Checkliste („nach 2 Losses Session beenden"). DB-Query ~120 s gecacht.""" @@ -3276,6 +3363,68 @@ class TradingEngine: except Exception as e: log.debug(f"pbreak_predictions auswerten: {e}") + def _evaluate_rec_outcomes(self): + """Traegt fuer offene Empfehlungs-Episoden den Forward-Return nach. + + ⚠ `candles_m1.time` ist ROHE Broker-Zeit (UTC+3), `rec_outcomes.ts` ist + lokale Epoch — der Offset muss drauf (dieselbe Falle wie bei + `_evaluate_pbreak_predictions`). Genommen wird der letzte M1-Close bis + zum Zielzeitpunkt; fehlen Bars (Wochenende, Broker-Ausfall), bleibt die + Zeile offen statt falsch bewertet zu werden. + + `hit` = 1, wenn der Kurs in Signalrichtung lief. Die Quote ist gegen 50 % + zu lesen, nicht gegen 0.""" + now = time.time() + if now - getattr(self, "_rec_eval_ts", 0.0) < 300: + return + self._rec_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 + offen = con.execute( + "SELECT id, ts, signal, price, atr, ret30, ret60 FROM rec_outcomes " + "WHERE done IS NULL OR done = 0").fetchall() + for r in offen: + d = 1 if r["signal"] == "LONG" else -1 + if not r["atr"] or r["atr"] <= 0: + con.execute("UPDATE rec_outcomes SET done=1 WHERE id=?", (r["id"],)) + continue + werte = {} + for feld, minuten in (("ret30", 30), ("ret60", 60)): + if r[feld] is not None: + werte[feld] = r[feld] + continue + ziel = r["ts"] + minuten * 60 + if now < ziel: + continue # noch nicht faellig + row = con.execute( + "SELECT c FROM candles_m1 WHERE time <= ? AND time > ? " + "ORDER BY time DESC LIMIT 1", + (ziel + off, ziel + off - 900)).fetchone() + if row: + werte[feld] = (row["c"] - r["price"]) * d / r["atr"] + sets, args = [], [] + for feld in ("ret30", "ret60"): + if feld in werte and r[feld] is None: + sets.append(f"{feld}=?"); args.append(round(werte[feld], 4)) + sets.append(f"hit{feld[3:]}=?"); args.append(1 if werte[feld] > 0 else 0) + faellig60 = now >= r["ts"] + 60 * 60 + if faellig60 and ("ret60" in werte or r["ret60"] is not None): + sets.append("done=?"); args.append(1) + elif now >= r["ts"] + 6 * 3600: + # Nach 6 h keine Bars mehr zu erwarten (Wochenende/Ausfall) → + # schliessen, damit die Zeile die Auswertung nicht ewig belegt. + sets.append("done=?"); args.append(1) + if sets: + args.append(r["id"]) + con.execute(f"UPDATE rec_outcomes SET {', '.join(sets)} WHERE id=?", + args) + con.commit(); con.close() + except Exception as e: + log.debug(f"rec_outcomes 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 @@ -3576,6 +3725,8 @@ class TradingEngine: "news_sentiment": (lambda sc: round(float(sc), 2) if sc is not None else None)( (self.news.sentiment or {}).get("score")), "alignment": self._alignment_cached(), + "block_mix": self._block_mix_cached(), + "rec_track": self._rec_track_cached(), # Kennzahlen der MANUELLEN Trades für die gleichnamige Karte # (2026-08-04). Rein deskriptiv; ~120 s gecacht, damit der heiße # Snapshot-Pfad nicht je Abruf die DB aggregiert. diff --git a/core/history.py b/core/history.py index 690ddee..b1dd16d 100644 --- a/core/history.py +++ b/core/history.py @@ -145,6 +145,22 @@ SCHEMA = [ ) """, """ + CREATE TABLE IF NOT EXISTS rec_outcomes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER, -- lokale Epoch, Beginn der EPISODE + signal TEXT, -- 'LONG' | 'SHORT' + conf_pct INTEGER, + tf TEXT, + price REAL, -- Kurs bei Ausgabe der Empfehlung + atr REAL, -- ATR_M5 zur Normierung + ret30 REAL, -- (Kurs[+30min] - price) x Richtung / atr + ret60 REAL, + hit30 INTEGER, -- 1 = Kurs lief in Signalrichtung + hit60 INTEGER, + done INTEGER -- 1 = beide Horizonte ausgewertet + ) + """, + """ CREATE TABLE IF NOT EXISTS verdict_votes ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER, -- lokale Epoch @@ -420,6 +436,52 @@ class HistoryLogger: conn.commit() return cur.lastrowid + def log_rec_outcome(self, *, signal: str, conf_pct: int, tf: str, + price: float, atr: float) -> int: + """Loggt EINE Empfehlungs-EPISODE zur spaeteren Selbst-Kalibrierung. + + ⚠ WARUM EPISODEN UND NICHT ZEILEN: die Empfehlung wird ~1x/Minute + geloggt; eine Marktlage erzeugt dutzende identische Zeilen. Wer die zaehlt, + haelt 20.000 Faelle fuer eine Stichprobe, obwohl es 1.000 sind + (`analyze_signal_live.py`). Hier wird genau EINE Zeile je Richtungswechsel + geschrieben. + + Ausgewertet wird separat (`engine._evaluate_rec_outcomes`) gegen + `candles_m1` — dasselbe Muster wie `pbreak_predictions`, das den Ausfall + des P(break)-Modells live aufgedeckt hat. Rueckgabe = Zeilen-ID.""" + with self._lock, self._connect() as conn: + cur = conn.execute(""" + INSERT INTO rec_outcomes (ts, signal, conf_pct, tf, price, atr) + VALUES (?,?,?,?,?,?) + """, (int(time.time()), signal, int(conf_pct or 0), tf or "", + float(price or 0.0), float(atr or 0.0))) + conn.commit() + return cur.lastrowid + + def rec_accuracy(self, last_n: int = 200) -> dict: + """Trefferquote der letzten `last_n` ausgewerteten Empfehlungs-Episoden. + + ⚠ Die Trefferquote ist gegen 50 % zu lesen, nicht gegen 0 — und der + Ø-Return gegen die Drift des Zeitraums. Gemessen ueber 1.071 Episoden lag + die Richtungstreffer-Quote bei 48–51 % (`analyze_signal_live.py`); diese + Zeile macht laufend sichtbar, ob sich daran etwas aendert.""" + with self._connect() as conn: + rows = conn.execute(""" + SELECT signal, ret30, ret60, hit30, hit60 FROM rec_outcomes + WHERE done = 1 ORDER BY ts DESC LIMIT ? + """, (int(last_n),)).fetchall() + if not rows: + return {"n": 0} + def q(feld, hit): + v = [r[feld] for r in rows if r[feld] is not None] + h = [r[hit] for r in rows if r[hit] is not None] + return ((round(100.0 * sum(h) / len(h), 1) if h else None), + (round(sum(v) / len(v), 3) if v else None), len(h)) + wr30, avg30, n30 = q("ret30", "hit30") + wr60, avg60, n60 = q("ret60", "hit60") + return {"n": len(rows), "wr30": wr30, "avg30": avg30, "n30": n30, + "wr60": wr60, "avg60": avg60, "n60": n60} + def pbreak_accuracy(self, period: str = "all") -> dict: """Trefferquote der Abprall/Durchbruch-Prognose (nur ausgewertete Zeilen).""" since, until = self._range_to_ts(period) @@ -521,38 +583,60 @@ class HistoryLogger: "pct": round(100.0 * (r["c"] or 0) / (r["n"] or 1), 1)} return out - def alignment_stats(self, last_n: int = 40) -> dict: - """Ausrichtungs-Split der letzten N geschlossenen Trades: liefen sie MIT, - GEGEN oder OHNE die Empfehlung (rec_signal beim Entry)? Für die ehrliche - Verhaltens-Anzeige im Order-Bestätigungsdialog (B0 als Software — die - gemessene Kern-Leckage sind diskretionäre Gegen-Signal-Trades).""" + def alignment_stats(self, last_n: int = 0) -> dict: + """Ausrichtungs-Split: liefen die Trades MIT, GEGEN oder OHNE die Empfehlung? + + ⚠⚠ ZWEI AENDERUNGEN AM 2026-08-06, beide gemessen begruendet: + (1) **Volle Historie statt der letzten 40 Trades** (`last_n=0`). Ueber + 1.216 geschlossene Trades ist der Befund beidhaelftig robust; ueber 40 + war er eine Momentaufnahme. Das ist die einzige Richtungsaussage ueber + die Gesamtempfehlung, die den 2-Stichproben-Test besteht — sie sollte + auf der ganzen Stichprobe stehen. + (2) **Zusaetzlich JE LOT normiert** (`pnl_lot`). Absolute Euro-Vergleiche + ueber Zeitraeume mit unterschiedlicher Positionsgroesse sind ungueltig + — am 04.08. sah ein reiner Groessenzuwachs wie ein Strategie-Effekt aus + (Ø +55 € gegen +19 €, je Lot aber identisch). Das Order-Dialog-Feld + zeigt deshalb beides. + + Gemessener Stand (06.08., ganze Historie, je Lot): + MIT n=354 WR 56,5 % +3,22 €/Lot (H1 +0,37 / H2 +5,62) + GEGEN n= 87 WR 40,2 % −6,60 €/Lot (H1 −7,01 / H2 −5,68) + OHNE n=775 WR 56,6 % +0,36 €/Lot (kippt zwischen den Haelften) + MIT und OHNE sind praktisch gleich — der Empfehlung zu FOLGEN bringt + gemessen nichts, nur sie zu VERLETZEN kostet. Sie ist ein Veto, keine + Prognose. + """ + sql = ("SELECT direction, rec_signal, pnl, lots FROM trades " + "WHERE exit_time IS NOT NULL AND pnl IS NOT NULL AND lots > 0 " + "ORDER BY exit_time DESC") + args: tuple = () + if last_n and last_n > 0: + sql += " LIMIT ?" + args = (last_n,) with self._connect() as conn: - rows = conn.execute(""" - SELECT direction, rec_signal, pnl FROM trades - WHERE exit_time IS NOT NULL AND pnl IS NOT NULL - ORDER BY exit_time DESC LIMIT ? - """, (last_n,)).fetchall() - out = {} - for key, match in (("with", True), ("against", False)): - grp = [] - for r in rows: - rec = (r["rec_signal"] or "").upper() - if rec not in ("LONG", "SHORT"): - continue - aligned = (rec == "LONG") == (r["direction"] == "BUY") - if aligned == match: - grp.append(r["pnl"]) + rows = conn.execute(sql, args).fetchall() + + def fasse(grp: list) -> dict: n = len(grp) - out[key] = {"n": n, - "wr": round(100 * sum(1 for p in grp if p > 0) / n) if n else None, - "pnl": round(sum(grp), 2)} - none_grp = [r["pnl"] for r in rows - if (r["rec_signal"] or "").upper() not in ("LONG", "SHORT")] - n = len(none_grp) - out["none"] = {"n": n, - "wr": round(100 * sum(1 for p in none_grp if p > 0) / n) if n else None, - "pnl": round(sum(none_grp), 2)} - return out + if not n: + return {"n": 0, "wr": None, "pnl": 0.0, "pnl_lot": None} + pnl = sum(r["pnl"] for r in grp) + lots = sum(r["lots"] for r in grp) or 0.0 + return {"n": n, + "wr": round(100 * sum(1 for r in grp if r["pnl"] > 0) / n), + "pnl": round(pnl, 2), + "pnl_lot": round(pnl / lots, 2) if lots > 0 else None} + + mit, gegen, ohne = [], [], [] + for r in rows: + rec = (r["rec_signal"] or "").upper() + if rec not in ("LONG", "SHORT"): + ohne.append(r) + elif (rec == "LONG") == (r["direction"] == "BUY"): + mit.append(r) + else: + gegen.append(r) + return {"with": fasse(mit), "against": fasse(gegen), "none": fasse(ohne)} # ══════════════════════════════════════════ # STATISTIK-QUERIES diff --git a/tests_rec_outcomes.py b/tests_rec_outcomes.py new file mode 100644 index 0000000..2c4e7ae --- /dev/null +++ b/tests_rec_outcomes.py @@ -0,0 +1,163 @@ +"""Szenario-Test der Selbst-Kalibrierung (rec_outcomes) — ohne MT5, auf DB-Kopie. + +Geprueft werden die Faelle, die live schiefgehen koennen: + 1) Episode wird EINMAL geloggt, nicht je Minute + 2) WARTEN re-armt → die naechste Richtung ist eine neue Episode + 3) Auswertung rechnet den Forward-Return korrekt (inkl. Broker-Offset) + 4) fehlende M1-Bars → Zeile bleibt offen statt falsch bewertet + 5) rec_accuracy aggregiert Trefferquote und Ø-Return richtig +""" +import os +import shutil +import sqlite3 +import sys +import tempfile +import time +import types + +sys.path.insert(0, r"c:\Users\ah\Downloads\tradingrobot") + +from core.engine import TradingEngine +from core.history import HistoryLogger + +QUELL = r"c:\Users\ah\Downloads\tradingrobot\oil_widget_history.db" +TMP = os.path.join(tempfile.mkdtemp(), "t.db") +shutil.copy(QUELL, TMP) +con = sqlite3.connect(TMP) +con.execute("DELETE FROM rec_outcomes") +con.commit() + +h = HistoryLogger(TMP) +fehler = [] + + +def pruef(lbl, ist, soll): + ok = ist == soll + if not ok: + fehler.append(lbl) + print(f" {'OK ' if ok else 'FEHLER'} {lbl:<50} ist={ist!r:<14} soll={soll!r}") + + +print("=" * 86) +print(" 1) Episoden-Logging: eine Zeile je Richtungswechsel, nicht je Minute") +print("=" * 86) + + +class E: + """Nur der Episoden-Teil aus `_run_analysis`, isoliert nachgestellt.""" + def __init__(self): + self._rec_epi_sig = None + self.history = h + + def tick(self, sig): + if sig in ("LONG", "SHORT") and sig != self._rec_epi_sig: + self.history.log_rec_outcome(signal=sig, conf_pct=70, tf="M5", + price=75.0, atr=0.2) + if sig in ("LONG", "SHORT", "WARTEN"): + self._rec_epi_sig = sig if sig != "WARTEN" else None + + +e = E() +for s in ["LONG"] * 5: # dieselbe Episode, 5 Minuten lang + e.tick(s) +pruef("5x LONG hintereinander → 1 Zeile", + con.execute("SELECT COUNT(*) FROM rec_outcomes").fetchone()[0], 1) +for s in ["SHORT"] * 3: # Flip = neue Episode + e.tick(s) +pruef("Flip auf SHORT → 2 Zeilen", + con.execute("SELECT COUNT(*) FROM rec_outcomes").fetchone()[0], 2) +e.tick("WARTEN"); e.tick("WARTEN") +pruef("WARTEN erzeugt keine Zeile", + con.execute("SELECT COUNT(*) FROM rec_outcomes").fetchone()[0], 2) +e.tick("SHORT") +pruef("nach WARTEN ist SHORT eine NEUE Episode", + con.execute("SELECT COUNT(*) FROM rec_outcomes").fetchone()[0], 3) + +print() +print("=" * 86) +print(" 2) Auswertung gegen candles_m1 (mit Broker-Offset)") +print("=" * 86) +con.execute("DELETE FROM rec_outcomes") +con.execute("DELETE FROM candles_m1 WHERE symbol='TESTSYM'") +OFF = 3 * 3600 +t0 = int(time.time()) - 7200 # vor 2 h → beide Horizonte faellig +# LONG @ 75.00, ATR 0.20; nach 30 min steht 75.10 (+0,5 ATR), nach 60 min 74.90 +con.execute("INSERT INTO rec_outcomes (ts,signal,conf_pct,tf,price,atr) " + "VALUES (?,?,?,?,?,?)", (t0, "LONG", 70, "M5", 75.00, 0.20)) +for minuten, kurs in ((30, 75.10), (60, 74.90)): + con.execute("INSERT OR REPLACE INTO candles_m1 (time,o,h,l,c,spread,tick_volume,symbol)" + " VALUES (?,?,?,?,?,?,?,?)", + (t0 + minuten * 60 + OFF, kurs, kurs, kurs, kurs, 20, 1, "TESTSYM")) +con.commit() + +eng = TradingEngine.__new__(TradingEngine) +eng._rec_eval_ts = 0.0 +eng.trader = types.SimpleNamespace(_broker_offset_s=lambda: OFF) +import core.engine as CE +_alt = CE.HISTORY_DB_FILE +CE.HISTORY_DB_FILE = TMP +eng._evaluate_rec_outcomes() +CE.HISTORY_DB_FILE = _alt + +r = con.execute("SELECT ret30,hit30,ret60,hit60,done FROM rec_outcomes").fetchone() +pruef("ret30 = (75.10-75.00)/0.20 = +0.5", round(r[0], 3), 0.5) +pruef("hit30 = 1 (lief in Signalrichtung)", r[1], 1) +pruef("ret60 = (74.90-75.00)/0.20 = -0.5", round(r[2], 3), -0.5) +pruef("hit60 = 0", r[3], 0) +pruef("done = 1", r[4], 1) + +print() +print("=" * 86) +print(" 3) SHORT-Richtung spiegelbildlich · fehlende Bars → bleibt offen") +print("=" * 86) +con.execute("DELETE FROM rec_outcomes") +# ⚠ Test-Kerzen aus Abschnitt 2 MUESSEN weg — sonst faellt die dortige +# 60-Minuten-Bar in das Fenster dieses Abschnitts (beide Anker liegen 2 h +# zurueck) und der Fall "keine Bar" waere gar nicht getestet. +con.execute("DELETE FROM candles_m1 WHERE symbol='TESTSYM'") +t1 = int(time.time()) - 7200 +con.execute("INSERT INTO rec_outcomes (ts,signal,conf_pct,tf,price,atr) " + "VALUES (?,?,?,?,?,?)", (t1, "SHORT", 70, "M5", 75.00, 0.20)) +con.execute("INSERT OR REPLACE INTO candles_m1 (time,o,h,l,c,spread,tick_volume,symbol)" + " VALUES (?,?,?,?,?,?,?,?)", + (t1 + 1800 + OFF, 74.9, 74.9, 74.9, 74.9, 20, 1, "TESTSYM")) +# fuer 60 min BEWUSST keine Bar → Zeile darf nicht faelschlich bewertet werden. +# ⚠ Dafuer muss das 60-Minuten-Fenster WIRKLICH leer sein: die DB-Kopie enthaelt +# ECHTE candles_m1 aus dem Live-Betrieb, und die liegen genau hier. Ohne dieses +# DELETE testet man nicht "keine Bar", sondern "echte Bar" (erster Versuch lieferte +# ret60 = -14,985 = der reale Kurs ~78). +con.execute("DELETE FROM candles_m1 WHERE time > ? AND time <= ?", + (t1 + 3600 + OFF - 900, t1 + 3600 + OFF)) +con.commit() +eng._rec_eval_ts = 0.0 +CE.HISTORY_DB_FILE = TMP +eng._evaluate_rec_outcomes() +CE.HISTORY_DB_FILE = _alt +r = con.execute("SELECT ret30,hit30,ret60,done FROM rec_outcomes").fetchone() +pruef("SHORT: Kurs faellt → ret30 = +0.5", round(r[0], 3), 0.5) +pruef("SHORT: hit30 = 1", r[1], 1) +pruef("ret60 ohne Bar bleibt NULL", r[2], None) +pruef("done bleibt offen (0/None)", bool(r[3]), False) + +print() +print("=" * 86) +print(" 4) rec_accuracy aggregiert") +print("=" * 86) +con.execute("DELETE FROM rec_outcomes") +for ret, hit in ((0.5, 1), (0.3, 1), (-0.4, 0), (-0.2, 0)): + con.execute("INSERT INTO rec_outcomes (ts,signal,conf_pct,tf,price,atr," + "ret30,hit30,ret60,hit60,done) VALUES (?,?,?,?,?,?,?,?,?,?,1)", + (int(time.time()), "LONG", 70, "M5", 75.0, 0.2, ret, hit, ret, hit)) +con.commit() +a = h.rec_accuracy(200) +pruef("n = 4", a["n"], 4) +pruef("Trefferquote 30' = 50 %", a["wr30"], 50.0) +pruef("Ø-Return 30' = +0.05", a["avg30"], 0.05) + +con.close() +print() +print("=" * 86) +print(" ERGEBNIS: " + ("alle Szenarien korrekt" if not fehler + else f"{len(fehler)} FEHLER: {fehler}")) +print("=" * 86) +sys.exit(1 if fehler else 0) diff --git a/web/app.js b/web/app.js index 1cc8499..aa007e6 100644 --- a/web/app.js +++ b/web/app.js @@ -172,6 +172,52 @@ function renderVerdict(v, snap) { `${a}` + `
${mod.detail || ""}
`; }).join(""); + + // ── Selbst-Kalibrierung: die Karte zeigt ihre eigene Trefferquote ───── + // ⚠ Gegen 50 % zu lesen, nicht gegen 0 — es ist eine Richtungsaussage. + // Ausgangsstand (analyze_signal_live.py, 1.071 Episoden): 48–51 %. + // Bis genug Episoden ausgewertet sind, steht hier ehrlich „sammelt noch". + // Null-sicher (s. Cache-Regel): neue IDs treffen evtl. auf alte, gecachte HTML. + const trkEl = $("vd-selfcal"); + if (trkEl) { + const tr = snap && snap.rec_track; + if (tr && tr.n >= 20 && tr.wr30 != null) { + const d = (x) => (x == null ? "—" : (x >= 0 ? "+" : "") + fmt(x, 3)); + const cls = tr.wr30 >= 55 ? "up" : tr.wr30 <= 45 ? "down" : ""; + trkEl.className = "vd-selfcal " + cls; + trkEl.textContent = + `🎯 eigene Trefferquote (letzte ${tr.n} Episoden): ${tr.wr30} % auf 30′` + + (tr.wr60 != null ? ` · ${tr.wr60} % auf 60′` : "") + + ` · Ø ${d(tr.avg30)}×ATR`; + trkEl.title = "Selbst-Kalibrierung: jede gerichtete Empfehlung wird nach " + + "30 und 60 Minuten gegen den echten Kurs ausgewertet. Gegen 50 % lesen, " + + "nicht gegen 0. Gemessener Ausgangsstand über 1.071 Episoden: 48–51 %."; + } else { + trkEl.className = "vd-selfcal dim"; + trkEl.textContent = `🎯 eigene Trefferquote: sammelt noch` + + (tr && tr.n ? ` (${tr.n} von 20 Episoden ausgewertet)` : ""); + trkEl.title = "Jede gerichtete Empfehlung wird nach 30/60 Minuten gegen " + + "den echten Kurs ausgewertet. Ab 20 ausgewerteten Episoden erscheint " + + "die Quote hier."; + } + } + + // ── Warum WARTEN? Verteilung der blockierenden Gates (heute) ─────────── + const blkEl = $("vd-blocks"); + if (blkEl) { + const bm = snap && snap.block_mix; + if (bm && bm.total) { + const pc = (n) => Math.round(100 * n / bm.total); + const top = (bm.top || []).map(x => `${x.grund} ${pc(x.n)} %`).join(" · "); + blkEl.textContent = `⛔ heute ${pc(bm.frei)} % ohne Block` + + (top ? ` · ${top}` : ""); + blkEl.title = "Welches Gate hält die Empfehlung gerade zurück — über den " + + "heutigen Tag gezählt. Ohne diese Zeile war „warum gab es so wenige " + + "Empfehlungen?" + '"' + " nur per Datenbank-Abfrage zu beantworten."; + } else { + blkEl.textContent = ""; + } + } } @@ -1171,8 +1217,16 @@ document.querySelectorAll(".btn[data-side]").forEach(btn => { const isWait0 = (sigNow === "WARTEN" || sigNow === "—"); if (al && cl.items.some(x => x.key === "signal" && x.state === "fail")) { const grp = isWait0 ? al.none : al.against, ref = al.with; - const f = g => (g && g.n >= 5) - ? `${g.wr}% Treffer · ${g.pnl >= 0 ? "+" : ""}${fmt(g.pnl, 0)} € (n${g.n})` : null; + // ⚠ JE LOT ist die belastbare Zahl: absolute Euro-Vergleiche sind bei + // wechselnder Positionsgroesse ungueltig (Lehre 04.08. — ein reiner + // Groessenzuwachs sah wie ein Strategie-Effekt aus). Seit 06.08. laeuft + // der Split ueber die VOLLE Historie (1.216 Trades) statt ueber 40. + const f = g => { + if (!g || g.n < 5) return null; + const lot = (g.pnl_lot === null || g.pnl_lot === undefined) ? "" + : ` · ${g.pnl_lot >= 0 ? "+" : ""}${fmt(g.pnl_lot, 2)} €/Lot`; + return `${g.wr}% Treffer${lot} · ${g.pnl >= 0 ? "+" : ""}${fmt(g.pnl, 0)} € (n${g.n})`; + }; const own = f(grp), rf = f(ref); if (own) body += `\n\n📊 Deine Trades ${isWait0 ? "OHNE" : "GEGEN"} Signal: ${own}`; if (rf) body += `\n📊 Mit Signal: ${rf}`; diff --git a/web/index.html b/web/index.html index aa99140..6dc8de0 100644 --- a/web/index.html +++ b/web/index.html @@ -6,7 +6,7 @@ Oil · MT5 - + @@ -106,6 +106,15 @@ Einstiegspreis und Exit, nicht aus der Trefferquote — lies das hier als Auslöser für eine Order am Level, nicht als Kursprognose. + +
+ +
SHORT
@@ -331,6 +340,6 @@
- + diff --git a/web/style.css b/web/style.css index 3ccd969..645a16c 100644 --- a/web/style.css +++ b/web/style.css @@ -221,6 +221,16 @@ main{display:grid;grid-template-columns:1fr 1fr;gap:11px;padding:11px} border:1px dashed var(--line);border-radius:var(--r-sm); padding:7px 10px;margin:0 0 11px} .vd-note b{color:var(--bright);font-weight:700} +/* Block-Verteilung + Selbst-Kalibrierung: beide bewusst leise (Kontext, keine + Handlungsaufforderung). Die Trefferquote faerbt nur bei klarer Abweichung von + den gemessenen ~50 %, damit Rauschen nicht wie ein Signal aussieht. */ +.vd-blocks{font-size:12px;color:var(--dim);margin:0 0 4px;font-family:var(--mono)} +/* ⚠ NICHT `.vd-track` nennen — so heisst bereits die Schiene der Bias-Nadel + (`.vd-meter .vd-track`). Eine gleichnamige Klasse haette sie ueberschrieben + und die Nadel unsichtbar gemacht. */ +.vd-selfcal{font-size:12px;color:var(--dim);margin:0 0 9px;font-family:var(--mono)} +.vd-selfcal.up{color:var(--up)} .vd-selfcal.down{color:var(--down)} +.vd-selfcal.dim{color:var(--dim);opacity:.75} .vd-main{flex:1;min-width:0} .vd-signal{font-size:30px;font-weight:800;color:var(--dim);line-height:1.1} .vd-signal.long{color:var(--up)} .vd-signal.short{color:var(--down)}