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:
co-authored by
Claude Opus 5
parent
5474c2be61
commit
47c596ed70
@@ -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"]))
|
||||
Reference in New Issue
Block a user