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>
160 lines
6.4 KiB
Python
160 lines
6.4 KiB
Python
"""`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"]))
|