Files
AH-Oil-Trader/tests/test_room_info.py
T
Axel HocksandClaude Opus 5 47c596ed70 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>
2026-08-10 09:09:27 +02:00

160 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""`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"]))