M15-Karte: Gate 3b zeigt Motor-Block + RAUM je Richtung

Anlass Trade #49671601 (-87,03 EUR). Die Praemisse "SL trotz LONG-
Empfehlung" stimmte nicht: 138 Zeilen zwischen Ein- und Ausstieg,
100 % WARTEN (entry_room 76 %, breakout_pending 19 %, min_conf 5 %).
Gesehen wurde "verboten: SHORT" - ein Veto. Dritter Fall derselben
Veto->Richtung-Umwandlung in dieser Karte (Code 08.08., Geometrie
10.08. frueh, jetzt die Lesart).

Der eigentliche Befund: entry_room und Gate 3 lesen DIESELBE Tatsache
("ein Level ist nah") mit umgekehrtem Vorzeichen. Beide gemessen,
beide richtig - ueber verschiedene Trades (Markt-Einstieg mit dem
Level als Hindernis vs. ruhende Order AM Level). Die Karte zeigte nur
die gruene Haelfte; die Block-Grund-Zeile war mit der Gesamtempfehlung
verschwunden.

Gebaut: Zeile 3b (#m15-motor) mit block_reason im Klartext + Raum je
Richtung. Reine Anzeige. Das Status-Banner bleibt bewusst unveraendert
- es auf gelb zu ziehen waere eine Strategie-Aenderung, und die einzige
Live-Zahl dafuer (-9,91 vs +3,24 EUR/Lot, n=76) hat ein KI, das die
Null enthaelt.

- wave_rec.room_info(): EINE Implementierung fuer Gate und Anzeige,
  _room_gate rechnet jetzt darueber (kein Nachbau)
- tests/test_room_info.py: Bitgleichheit ueber 2.000 Zufallslagen x 3
  Signale, Gate feuerte >300x; Grenzfall-Test fuer die Rundung
- v=160

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Axel Hocks
2026-08-10 09:09:27 +02:00
co-authored by Claude Opus 5
parent 5474c2be61
commit 47c596ed70
6 changed files with 375 additions and 19 deletions
+159
View File
@@ -0,0 +1,159 @@
"""`room_info` darf das entry_room-Gate NICHT veraendert haben.
ANLASS (2026-08-10): der Raum zum Gegenlevel lag bisher nur als Fliesstext in
`reasons` und war nicht auswertbar. Fuer die Anzeige in der M15-Karte wurde er
herausgezogen — als EINE Implementierung fuer Gate und Anzeige, nicht als zweite
Kopie (die Divergenz-Falle, die im Projekt dreimal zugeschlagen hat).
⚠⚠ MIGRATIONS-REGEL: eine Refaktorierung ist nur dann eine, wenn das Verhalten
bitgenau gleich bleibt. Die ALTE Fassung liegt dafuer woertlich eingefroren hier
im Test (aus `git show`) und laeuft gegen die neue.
⚠ Der gefaehrlichste Fall ist die RUNDUNG: `dist_atr` wird auf 2 Stellen
gerundet ausgewiesen. Wuerde `frei` auf dem gerundeten Wert entschieden, kippte
ein Grenzfall (0,5951 → 0,60 → faelschlich frei). Genau dafuer gibt es
`test_grenzfall_rundung` — ohne den waere der Fehler unsichtbar, weil er nur
etwa jeden hundertsten Fall trifft.
"""
from __future__ import annotations
import random
import pytest
from core.wave_rec import WaveRecommender
class _Stub:
"""Minimal-Recommender: nur die drei Felder, die beide Methoden lesen."""
def __init__(self, x, ph, pl, atr):
self._entry_room_atr = x
self._pb_levels = {"ph": ph, "pl": pl}
self._pb_feats = {"atr": atr}
room_info = WaveRecommender.room_info
_room_gate = WaveRecommender._room_gate
# ── die ALTE Fassung, woertlich eingefroren (Stand vor dem Umbau) ─────────
def _alt_room_gate(s, rec, cur):
x = s._entry_room_atr
sig = rec.get("signal")
if x <= 0 or sig == "WARTEN":
return rec
lv = s._pb_levels or {}
pf = s._pb_feats 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:
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, "block": "entry_room",
"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"]}
def _rec(sig):
return {"signal": sig, "conf_pct": 70, "score": 1.0, "setup": "WAVE",
"regime": None, "rsi": None, "reasons": ["Test"]}
def test_bitgleich_ueber_zufallsfaelle():
"""2.000 Zufallslagen — alte und neue Fassung muessen IDENTISCH entscheiden.
⚠ Die Level werden eng um den Kurs gestreut, damit die Gate-Grenze wirklich
oft getroffen wird. Liegen alle Level weit weg, blockt nie etwas und der
Test belegt nichts (dieselbe Falle wie bei `test_squeeze_scan`, wo erst die
Vola-Wechsel die Bedingungen durchlaufen liessen)."""
r = random.Random(4711)
getroffen = 0
for _ in range(2000):
cur = 79.0 + r.uniform(-2, 2)
atr = r.choice([0.06, 0.12, 0.2, 0.35])
x = r.choice([0.0, 0.3, 0.6, 1.0])
ph = [round(cur + r.uniform(-1.0, 1.0), 3) for _ in range(r.randint(0, 5))]
pl = [round(cur + r.uniform(-1.0, 1.0), 3) for _ in range(r.randint(0, 5))]
s = _Stub(x, ph, pl, atr)
for sig in ("LONG", "SHORT", "WARTEN"):
neu = s._room_gate(_rec(sig), cur)
alt = _alt_room_gate(s, _rec(sig), cur)
assert neu == alt, f"Abweichung bei {sig} cur={cur} atr={atr} x={x}"
if neu.get("block") == "entry_room":
getroffen += 1
# Ein Test, in dem das Gate nie feuert, belegt nichts.
assert getroffen > 300, f"Gate feuerte nur {getroffen}x — Testdaten zu lasch"
def test_grenzfall_rundung():
"""dist = 0,5951 liegt UNTER dem Gate 0,6 — gerundet waere es 0,60.
Wuerde `frei` auf dem gerundeten Wert entschieden, liesse das Gate diesen
Fall faelschlich durch. Das ist der einzige Weg, wie der Umbau das Verhalten
haette veraendern koennen."""
cur, atr, x = 79.0, 0.1, 0.6
lvl = cur + 0.05951 # dist = 0,5951
s = _Stub(x, [round(lvl, 5)], [], atr)
info = s.room_info(cur)
assert info["long"]["dist_atr"] == 0.6, "Anzeige rundet wie erwartet"
assert info["long"]["frei"] is False, "…aber die ENTSCHEIDUNG nutzt den Rohwert"
assert s._room_gate(_rec("LONG"), cur)["block"] == "entry_room"
# ── die neue Anzeige-Faehigkeit ──────────────────────────────────────────
def test_beide_richtungen_werden_geliefert():
"""Die Karte gibt keine Richtung vor → sie braucht den Raum fuer beide."""
cur = 79.0
s = _Stub(0.6, [79.30], [78.20], 0.2)
info = s.room_info(cur)
assert info["long"]["level"] == 79.30
assert info["long"]["dist_atr"] == 1.5 and info["long"]["frei"] is True
assert info["short"]["level"] == 78.20
assert info["short"]["dist_atr"] == 4.0 and info["short"]["frei"] is True
assert info["gate"] == 0.6 and info["atr_m5"] == 0.2
def test_der_reale_fall_vom_10_08():
"""Trade #49671601: BUY, Widerstand praktisch AUF dem Kurs.
ctx_dist_res_atr war 0,07 bei ATR_M5 0,12 (dem Floor) — der Widerstand lag
rund 0,008 $ voraus, der Stop 0,560 $ zurueck. Genau diese Lage muss die
Karte kuenftig ANZEIGEN."""
cur, atr = 79.065, 0.12
s = _Stub(0.6, [79.073], [78.99], atr)
info = s.room_info(cur)
assert info["long"]["frei"] is False # kein Raum nach oben
assert info["long"]["dist_atr"] < 0.1
assert s._room_gate(_rec("LONG"), cur)["block"] == "entry_room"
def test_freie_bahn_ohne_level():
"""Kein Level voraus = bestes gemessenes Segment → frei, nicht geblockt."""
s = _Stub(0.6, [], [], 0.2)
info = s.room_info(79.0)
assert info["long"] == {"level": None, "dist_atr": None, "frei": True}
assert s._room_gate(_rec("LONG"), 79.0).get("block") is None
def test_ohne_atr_kein_absturz():
"""Kaltstart: keine pb_feats → fail-open, kein Gate, keine Exception."""
s = _Stub(0.6, [79.3], [78.2], None)
info = s.room_info(79.0)
assert info["atr_m5"] is None and info["long"] is None
assert s._room_gate(_rec("LONG"), 79.0).get("block") is None
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))