Stufe 1: voller Entscheidungskontext beim Einstieg + Karte "Manuelle Trades"

Frage: kann der Bot aus den manuellen Trades lernen? Vorpruefung: noch NICHT.
1) Die entscheidenden Merkmale wurden nie gespeichert - beim Einstieg nur 5
   Felder, davon ai_sentiment in 1,4 % und news_score in 34 % befuellt.
2) Das naive Lernziel ("wann steigt er ein") braucht unbeschriftete Negative;
   brauchbar ist nur "welche Einstiege liefen gut" (Labels ueber P&L).
3) Die scheinbaren Muster sind Einzeltage: Stunde 13:00 zeigt +478 EUR ueber
   n=69, davon +382 EUR (80 %) aus den fuenf Trades des 04.08.

Gebaut wurde Stufe 1 - reine Telemetrie, kein Verhalten:
- trades um 16 ctx_*-Spalten erweitert (DB-Backup .bak-2026-08-04, 1.148 Zeilen
  unveraendert): p_break_target/-stop, dist_res_atr/dist_sup_atr, structure,
  squeeze, htf_trend/h1_trend, spread_atr, session, block_reason, conf_pct,
  atr_m5, bias, cone_pos, hour
- log_trade_open haengt die Spalten dynamisch an und ignoriert unbekannte
  Schluessel -> aeltere DB ohne Migration laeuft weiter (fail-open)
- Kette: engine._run_analysis -> trader.set_open_context(ctx=) -> log_trade_open

Fallstrick beim Bau: wave_snap existiert in _run_analysis NICHT (nur in
snapshot()). Der erste Entwurf haette einen NameError erzeugt, den das umgebende
except still geschluckt haette - der Kontext waere dauerhaft leer geblieben ohne
dass es auffaellt. Jetzt self.wave.snapshot(); vd zusaetzlich per locals()-Guard.

Verifiziert auf einer DB-KOPIE: alle 16 Spalten korrekt geschrieben, unbekannter
Schluessel ignoriert, Basisfelder unberuehrt. Snapshot liefert manual_stats.

Dashboard-Karte "Manuelle Trades" (#card-manual, v=134): heute / 30 Tage /
gesamt / Bot-Vergleich plus Split mit vs ohne Signal-Deckung. Backend
history.manual_stats(30) mit ~120-s-Cache wie _alignment_cached. Bewusst neutral
gefaerbt und deskriptiv; eine Reifegrad-Zeile nennt die ctx-Abdeckung (heute
0 %), damit die Karte nicht ueberschaetzt wird.

Stufe 2 NICHT gebaut: erst bei ausreichender ctx-Abdeckung messen, was Gewinner
von Verlierern unterscheidet (Fit H1, validiert H2, AUC + Kalibrierung). Ergebnis
waere ein Hinweis im Order-Dialog, ausdruecklich KEIN Auto-Entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Axel Hocks
2026-08-04 16:34:26 +02:00
co-authored by Claude Opus 5
parent 7432be0e8c
commit da7d8f5b90
7 changed files with 284 additions and 18 deletions
+74 -1
View File
@@ -1474,6 +1474,59 @@ class TradingEngine:
log.debug(f"log_verdict_votes: {e}")
# Kontext für Trade-Logging (set_open_context beim Öffnen)
# ── Voller Entscheidungszustand beim Einstieg (Stufe 1 „Manuelle Trades",
# 2026-08-04). Bis dahin wurden 5 Felder gespeichert, davon zwei fast nie
# befüllt — die Marktlage eines manuellen Einstiegs war damit NICHT
# rekonstruierbar. Alles hier ist bereits berechnet (Snapshot-Teile), es
# wird nur eingesammelt. Reine Telemetrie, kein Verhalten.
_ctx = {}
try:
# ⚠ `wave_snap` existiert in dieser Methode NICHT (anders als in
# `snapshot()`) — hier den Snapshot selbst holen. Ein NameError wäre vom
# `except` unten still geschluckt worden und hätte den Kontext dauerhaft
# leer gelassen, ohne dass es jemand merkt.
_ws = self.wave.snapshot() or {}
_bid = s.get("bid")
_atr5 = (_ws.get("pb_feats") or {}).get("atr")
_sh = self._last_sr_close_hint or {}
_sa = self._last_stop_approach or {}
_dl = self._draw_levels() or {}
_res = (_dl.get("res") or [None])[0]
_sup = (_dl.get("sup") or [None])[0]
_st = (self.structure.snapshot() or {}).get("trend")
_sq = (_ws.get("squeeze") or {}).get("state")
_ses = (session_state() or {}).get("phase")
_cone = self._last_cone or {}
_c120 = next((l for l in (_cone.get("levels") or [])
if l.get("band") == 80 and l.get("minutes") == 120), None)
_ctx = {
"p_break_target": _sh.get("p_break"),
"p_break_stop": _sa.get("p_break"),
"dist_res_atr": (round((_res - _bid) / _atr5, 2)
if (_res and _bid and _atr5) else None),
"dist_sup_atr": (round((_bid - _sup) / _atr5, 2)
if (_sup and _bid and _atr5) else None),
"structure": _st,
"squeeze": _sq,
"htf_trend": _ws.get("htf_trend"),
"h1_trend": _ws.get("h1_trend"),
"spread_atr": (round(float(s.get("spread") or 0) / _atr5, 3)
if _atr5 else None),
"session": _ses,
"block_reason": rec.get("block"),
"conf_pct": rec.get("conf_pct"),
"atr_m5": (round(_atr5, 4) if _atr5 else None),
# `vd` entsteht oben in einem try — bei einem Fehler dort wäre es
# gar nicht definiert. `locals()`-Guard statt NameError.
"bias": (locals().get("vd") or {}).get("bias"),
# Position im 120-min-Kegel: 0 = unteres Band, 1 = oberes
"cone_pos": (round((_bid - _c120["lo"])
/ max(_c120["hi"] - _c120["lo"], 1e-9), 3)
if (_c120 and _bid) else None),
"hour": datetime.now(_BERLIN).hour,
}
except Exception as e:
log.debug(f"open-context: {e}")
self._last_rec = {
"rec_signal": rec.get("signal"),
"rec_score": rec.get("score"),
@@ -1482,7 +1535,8 @@ class TradingEngine:
"setup": rec.get("setup"),
"regime": None,
"rsi": s.get("rsi_m15"),
"news_score": None,
"news_score": _news,
"ctx": _ctx,
}
def _check_flip_close(self):
@@ -2682,6 +2736,21 @@ class TradingEngine:
# ══════════════════════════════════════════════════════════════════════
_SNAP_TTL_S = 0.8 # geteilter Snapshot gilt so lange als frisch
def _manual_stats_cached(self) -> dict | None:
"""Kennzahlen der manuellen Trades für die Karte „Manuelle Trades"
(2026-08-04). Gleiches Cache-Muster wie `_alignment_cached` — die
Aggregation läuft über die ganze `trades`-Tabelle und hat im 1-s-Snapshot
nichts zu suchen."""
now = time.time()
if now - getattr(self, "_manual_ts", 0.0) > 120:
try:
self._manual_cache = self.history.manual_stats(30)
except Exception as e:
log.debug(f"manual_stats: {e}")
self._manual_cache = None
self._manual_ts = now
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
@@ -3220,6 +3289,10 @@ 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(),
# 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.
"manual_stats": self._manual_stats_cached(),
# Live-Handelskosten relativ zur Vola (2026-07-24): Spread/ATR(M5).
# Gemessen (`backtest_realcosts.py`): Ø 0,265×ATR, Nacht 0,320,50 =
# Kostenfalle, 1517 Uhr ~0,13 = günstig. Reine Anzeige (Chip) — die
+80 -13
View File
@@ -270,26 +270,45 @@ class HistoryLogger:
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)."""
news_score: float | None = None,
ctx: dict | None = None):
"""Loggt das Öffnen eines Trades. Idempotent über UNIQUE(ticket).
`ctx` = vollständiger Entscheidungszustand beim Einstieg (Stufe 1 des
„Manuelle Trades"-Moduls, 2026-08-04). Bis dahin wurden nur 5 Felder
gespeichert — davon `ai_sentiment` in 1,4 % und `news_score` in 34 % der
Fälle befüllt. Damit war NICHT rekonstruierbar, in welcher Marktlage der
User eingestiegen ist, und jede Auswertung seiner Diskretion blieb
Spekulation. Alle `ctx_*`-Werte werden im Snapshot ohnehin berechnet;
hier werden sie nur mitgeschrieben (reine Telemetrie, kein Verhalten).
Unbekannte Schlüssel werden ignoriert — so bricht ein späterer Ausbau
nichts."""
# 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())
# Kontext-Spalten dynamisch anhängen — nur solche, die es in der Tabelle
# wirklich gibt. So läuft eine ältere DB ohne Migration weiter (fail-open),
# statt beim INSERT zu werfen.
ctx = ctx or {}
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))
vorhanden = {r[1] for r in conn.execute("PRAGMA table_info(trades)")}
extra = [(f"ctx_{k}", v) for k, v in ctx.items()
if f"ctx_{k}" in vorhanden and v is not None]
spalten = ("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")
werte = [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]
for name, v in extra:
spalten += f", {name}"
werte.append(v)
platz = ",".join("?" * len(werte))
conn.execute(f"INSERT OR IGNORE INTO trades ({spalten}) VALUES ({platz})",
werte)
conn.commit()
# Kein Telegram bei Einstieg — nur Ergebnis (Close) wird gesendet
@@ -454,6 +473,54 @@ class HistoryLogger:
pattern, pattern_w, orderbook, orderbook_w, liqtrend, liqtrend_w))
conn.commit()
def manual_stats(self, tage: int = 30) -> dict:
"""Kennzahlen NUR der manuellen Trades — Datengrundlage der Dashboard-Karte
„Manuelle Trades" (2026-08-04).
⚠ Bewusst DESKRIPTIV, nicht prädiktiv. Der volle Entscheidungszustand wird
erst seit dem 04.08. mitgeschrieben (`ctx_*`); bis genug davon vorliegt,
wäre jede Vorhersage aus diesen Zahlen Rauschen. Die Karte zeigt deshalb,
was die eigene Historie SAGT, und rechnet nichts hoch.
⚠ `ctx_abdeckung` macht sichtbar, wie weit der neue Kontext schon reicht —
ohne diese Zahl könnte man die Karte für aussagekräftiger halten, als sie ist.
"""
MAN = ("(setup IS NULL OR (setup NOT LIKE 'AUTOSIG%' "
"AND setup NOT LIKE 'SQUEEZE%'))")
def agg(row):
n = row["n"] or 0
return {"n": n, "wr": round(100.0 * (row["w"] or 0) / n, 1) if n else 0.0,
"sum": round(row["s"] or 0.0, 2),
"avg": round((row["s"] or 0.0) / n, 2) if n else 0.0}
out = {"tage": tage}
with self._connect() as conn:
conn.row_factory = sqlite3.Row
q = (f"SELECT COUNT(*) n, SUM(CASE WHEN pnl>0 THEN 1 ELSE 0 END) w, "
f"SUM(pnl) s FROM trades WHERE pnl IS NOT NULL AND {MAN}")
seit = f"AND exit_time >= strftime('%s','now','-{int(tage)} days')"
out["fenster"] = agg(conn.execute(f"{q} {seit}").fetchone())
out["gesamt"] = agg(conn.execute(q).fetchone())
out["heute"] = agg(conn.execute(
f"{q} AND exit_time >= strftime('%s','now','start of day')").fetchone())
# Mit / ohne Signal-Deckung — die dokumentierte Kern-Frage
for lbl, cond in (("mit_signal", "rec_signal IN ('LONG','SHORT')"),
("ohne_signal", "rec_signal='WARTEN'")):
out[lbl] = agg(conn.execute(f"{q} {seit} AND {cond}").fetchone())
# Bot zum Vergleich (dieselbe Fensterlogik)
qb = ("SELECT COUNT(*) n, SUM(CASE WHEN pnl>0 THEN 1 ELSE 0 END) w, "
"SUM(pnl) s FROM trades WHERE pnl IS NOT NULL AND "
"(setup LIKE 'AUTOSIG%' OR setup LIKE 'SQUEEZE%')")
out["bot"] = agg(conn.execute(f"{qb} {seit}").fetchone())
# Abdeckung des NEUEN Kontexts — ehrlicher Reifegrad-Indikator
r = conn.execute(
f"SELECT COUNT(*) n, SUM(CASE WHEN ctx_atr_m5 IS NOT NULL THEN 1 ELSE 0 END) c "
f"FROM trades WHERE {MAN}").fetchone()
out["ctx_abdeckung"] = {
"n": r["n"] or 0, "mit_ctx": r["c"] or 0,
"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
+6 -1
View File
@@ -315,11 +315,14 @@ class TradeManager:
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):
setup=None, regime=None, rsi=None, news_score=None,
ctx=None):
# `ctx` = voller Entscheidungszustand (2026-08-04, s. `history.log_trade_open`)
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,
"ctx": ctx or {},
}
def _log_open(self, ticket: int, sym: str, direction: str, entry_price: float):
@@ -351,6 +354,8 @@ class TradeManager:
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"),
# Voller Entscheidungszustand (2026-08-04, „Manuelle Trades")
ctx=ctx.get("ctx"),
)
except Exception as e:
log_hist.error(f"log_trade_open: {e}")