"""`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"]))