"""Angriffsfaelle fuer den Abbau des Restbestands (Paket AB): abbau_restbestand.py und seine WSL-Teile. Ersteller: Pascal Michel Erstellt: 2026-10-07 Doku: LIESMICH.md in diesem Ordner, Abschnitt "Paket AB" Koordinator-Werkzeug, nicht fuers Repo. Jeder Fall ist eine Funktion test_; so laufen dieselben Faelle als Skript und unter pytest (mutanten_lauf.py mit mutanten.toml im Planordner). Abdichtung, doppelt: Alle Pfade nach draussen zeigen unten ins Leere (wsl.exe, die WSL-Skripte, die Instanzordner), und jeder Fall ersetzt wsl() durch die Attrappe Welt, die jeden Aufruf zaehlt und nichts startet; der Fall zu wsl() selbst ersetzt starte (subprocess.run). Kein Fall erreicht wsl.exe, das Netz oder ntfy. Die Attrappe lehnt wie CPython jedes Argument mit NUL ab. Die Faelle test_wsl_* fahren bash und die Venvs in WSL gegen Wegwerf-Instanzordner mit einem falschen scripts/killswitch.py, dem erfundenen Wort TESTWORT-NICHT-ECHT, der erfundenen Kontonummer DUQ123456 und erfundenen Betraegen, nie gegen einen echten Instanzordner. Unter Windows sind sie uebersprungen. Aufruf unter Windows: python -I -X utf8 abbau_restbestand_angriffe.py Aufruf in WSL: /home/claude/venvs/trading/bin/python -I abbau_restbestand_angriffe.py Exit 0, wenn jeder Fall wie erwartet ausgeht. """ import contextlib import io import json import subprocess import sys import tempfile from collections import namedtuple from datetime import datetime from datetime import timedelta from datetime import timezone from pathlib import Path from types import SimpleNamespace HIER = Path(__file__).resolve().parent sys.path.insert(0, str(HIER)) import abbau_restbestand as ab # noqa: E402 # Abdichtung (Vorfall 2026-09-26: ein Mutant schickte ueber den absoluten WSL_SKRIPT Meldungen ans # Telefon). Jeder Pfad nach draussen zeigt ins Leere, bevor ein Fall laeuft. ab.WSL = str(Path(tempfile.gettempdir()) / "abbau-angriffe-kein-wsl" / "wsl.exe") ab.WSL_SKRIPT = "/nicht-vorhanden/abendkontrolle_wsl.py" ab.ALPACA_LESEN = "/nicht-vorhanden/abbau_alpaca_lesen.py" ab.IBKR_LESEN = "/nicht-vorhanden/abbau_ibkr_lesen.sh" ab.GLATTSTELLEN = "/nicht-vorhanden/abbau_glattstellen.sh" ab.ORDNER_WSL = {"alpaca": "/nicht-vorhanden/alpaca", "ibkr": "/nicht-vorhanden/ibkr"} WRAPPER = HIER / "abbau_glattstellen.sh" IBKR_SKRIPT = HIER / "abbau_ibkr_lesen.sh" NUL = chr(0) TESTWORT = "TESTWORT-NICHT-ECHT" KONTO = "DUQ123456" BETRAG = "12345.67" BETRAG_OHNE_GELDWORT = "98.76" JETZT = "2026-10-08T15:45:00" GRUND = ( "Abbau Restbestand nach Netzschalter-Abriss 2026-10-07; Auftrag Pascal 2026-10-07, " "ausgefuehrt von der Aufgabe Abbau Restbestand" ) LOG = "projekt/data/paper/abbau/abbau.log" ERGEBNIS = "projekt/data/paper/abbau/abbau.json" OFFEN = {"offen": True, "sekunden_bis_schluss": 6 * 3600} ZU = {"offen": False, "sekunden_bis_schluss": 18 * 3600} FLACH = {"positionen": [], "orders": 0} AAPL10 = {"positionen": [("AAPL", 10)], "orders": 0} IBKR7 = { "positionen": [ ("AAPL.XNAS", 10), ("MSFT.XNAS", 10), ("NVDA.XNAS", 20), ("JPM.XNYS", 10), ("CAT.XNYS", 10), ("KO.XNYS", 20), ("NEE.XNYS", 20), ], "orders": 0, } WRAPPER_AUSGABE = ( "Zustand vorher: angehalten\nBroker: alpaca\nErgebnis: glattstellen, erfolgreich=True\n" "Zustand nachher: gesperrt\n" ) # Ein falsches scripts/killswitch.py fuer die Faelle test_wsl_*: schreibt seine Argumente (mit dem Wort) # in den Wegwerf-Ordner, druckt das Wort auf stderr und eine erfundene Kontonummer und erfundene Betraege # auf stdout. Nichts davon darf aus dem Wrapper herauskommen. FALSCHER_KILLSWITCH = """import sys from pathlib import Path Path("aufgerufen").write_text(" ".join(sys.argv[1:]), encoding="utf-8") print("Zustand vorher: aktiv") print("Interpreter: " + sys.executable) print("Argumente: " + " ".join(sys.argv[1:])) print("Ergebnis: glattstellen, erfolgreich=False") print(" Fehler: Kontowert 12345.67 USD nicht gedeckt") print(" Fehler: Order an DUQ123456 abgelehnt, limit 98.76") print("Zustand nachher: gesperrt", flush=True) print("Abgelehnt, es wurde nichts geaendert: " + sys.argv[-1], file=sys.stderr) sys.exit(int(Path("exit.txt").read_text(encoding="utf-8"))) """ Roh = namedtuple("Roh", "exit text") class Uebersprungen(Exception): """Ein Fall, der bash und die Venvs in WSL braucht, unter Windows.""" def nur_in_wsl() -> None: if sys.platform == "win32": raise Uebersprungen("nur in WSL") def alpaca_json(uhr, bestand, fehler="BrokerFehler: Test") -> str: """Die JSON-Zeile, die abbau_alpaca_lesen.py drucken wuerde; fehler ist der bestand_fehler.""" daten = {"uhr": uhr, "uhr_fehler": None, "positionen": None, "orders": None, "bestand_fehler": None} if uhr is None: daten["uhr_fehler"] = "BrokerFehler: Marktuhr nicht lesbar" if bestand is None: daten["bestand_fehler"] = fehler else: daten["positionen"] = [{"symbol": symbol, "menge": menge} for symbol, menge in bestand["positionen"]] daten["orders"] = bestand["orders"] return json.dumps(daten) + "\n" def ibkr_text(bestand, schluss="ERGEBNIS: gelesen") -> str: """stdout von scripts/ibkr_pruefen.py lesen in der gemessenen Form, ohne Kontonummer.""" zeilen = [ "verbunden: Gateway 127.0.0.1:4004, Client-ID 12, Handelsmodus paper, Konto XX (8 Zeichen), bestaetigt", "Konto: Kontowert, Bargeld und Kaufkraft gelesen (Betraege werden nicht ausgegeben)", f"Positionen: {len(bestand['positionen'])}", ] zeilen += [f" {kennung} {menge}" for kennung, menge in bestand["positionen"]] zeilen += [f"Offene Orders: {bestand['orders']}", schluss] return "\n".join(zeilen) + "\n" def unit_text(stand) -> str: """Ausgabe von systemctl show --property=LoadState,ActiveState: Text als Roh, (LoadState, ActiveState) als Paar, oder nur ActiveState einer geladenen Unit.""" if isinstance(stand, Roh): return stand.text laden, aktiv = stand if isinstance(stand, tuple) else ("loaded", stand) return f"LoadState={laden}\nActiveState={aktiv}\n" class Welt: """Attrappe fuer alles hinter ab.wsl(): Units, Bestaende, Wrapper, Meldung. Zaehlt jeden Aufruf.""" def __init__(self, *, units=("inactive", "inactive"), uhr=OFFEN, alpaca=AAPL10, ibkr=IBKR7, alpaca_nachher=FLACH, ibkr_nachher=FLACH, alpaca_fehler="BrokerFehler: Test", wrapper_exit=0, wrapper_wirft=False, echter_wrapper=None): self.units = dict(zip(ab.INSTANZEN, units)) self.uhr = uhr self.bestand = {"alpaca": alpaca, "ibkr": ibkr} self.nachher = {"alpaca": alpaca_nachher, "ibkr": ibkr_nachher} self.alpaca_fehler = alpaca_fehler self.wrapper_exit = wrapper_exit self.wrapper_wirft = wrapper_wirft self.echter_wrapper = echter_wrapper self.aufrufe = [] self.glatt = [] self.ordner = [] self.meldungen = [] self.schlaf = [] self.gelesen = {"alpaca": 0, "ibkr": 0} def schlafe(self, sekunden): self.schlaf.append(sekunden) def __call__(self, argumente, timeout): argumente = list(argumente) if any(NUL in argument for argument in argumente): raise ValueError("embedded null character") self.aufrufe.append(argumente) if argumente[:2] == ["systemctl", "show"]: instanz = next(name for name, unit in ab.UNITS.items() if unit == argumente[-1]) return 0, unit_text(self.units[instanz]), "" if argumente[:2] == [ab.ALPACA_PY, ab.ALPACA_LESEN]: self.gelesen["alpaca"] += 1 return 0, alpaca_json(self.uhr, self.bestand["alpaca"], self.alpaca_fehler), "" if argumente[:2] == ["bash", ab.IBKR_LESEN]: self.gelesen["ibkr"] += 1 stand = self.bestand["ibkr"] if stand is None: return 4, "", "Voraussetzung fehlt: Gateway nicht erreichbar\n" if isinstance(stand, Roh): return stand.exit, stand.text, "" return 0, ibkr_text(stand), "" if argumente[:2] == ["bash", ab.GLATTSTELLEN]: instanz = argumente[2] self.glatt.append(instanz) self.ordner.append(argumente[3]) if self.wrapper_wirft: raise subprocess.TimeoutExpired(argumente, timeout) self.bestand[instanz] = self.nachher[instanz] if self.echter_wrapper is not None: return self.echter_wrapper(argumente[2:]) return self.wrapper_exit, WRAPPER_AUSGABE, "" if argumente[:3] == [ab.WSL_PY, ab.WSL_SKRIPT, "--meldung"]: self.meldungen.append((argumente[3], argumente[4])) return 0, '{"meldung": "gesendet"}\n', "" raise AssertionError(f"unbekannter Aufruf: {argumente!r}") @contextlib.contextmanager def attrappe(welt): """Ersetzt wsl() und schlafe() fuer die Dauer eines Falls und stellt beide danach zurueck.""" alt = ab.wsl, ab.schlafe ab.wsl, ab.schlafe = welt, welt.schlafe try: yield welt finally: ab.wsl, ab.schlafe = alt def lies_json(text): try: return json.loads(text) if text else None except ValueError: return None def lauf_mit(welt, *, jetzt=JETZT, modus="scharf", ergebnis_vorher=None): """Ein Lauf von ab.main() in Wegwerf-Ordnern fuer Projekt und Vault; gibt alles Sichtbare zurueck.""" with tempfile.TemporaryDirectory() as tmp: tmp = Path(tmp) projekt, vault = tmp / "projekt", tmp / "vault" projekt.mkdir() (vault / "Inbox").mkdir(parents=True) ablage = projekt / ab.ABLAGE_TEIL if ergebnis_vorher is not None: ablage.mkdir(parents=True) (ablage / ab.ERGEBNIS_NAME).write_text(ergebnis_vorher, encoding="utf-8") puffer = io.StringIO() with attrappe(welt), contextlib.redirect_stdout(puffer): code = ab.main(["--modus", modus, "--jetzt", jetzt, "--projekt", str(projekt), "--vault", str(vault)]) ergebnis = ablage / ab.ERGEBNIS_NAME log = ablage / ab.LOG_NAME notizen = sorted((vault / "Inbox").glob("*.md")) text = ergebnis.read_text(encoding="utf-8") if ergebnis.exists() else None return SimpleNamespace( code=code, welt=welt, ausgabe=puffer.getvalue(), dateien=sorted(p.relative_to(tmp).as_posix() for p in tmp.rglob("*") if p.is_file()), ergebnis_text=text, json=lies_json(text), log=log.read_text(encoding="utf-8") if log.exists() else "", notiz=notizen[0].read_text(encoding="utf-8") if notizen else "", ) def titel_von(e) -> list[str]: return [titel for titel, _ in e.welt.meldungen] def kurz(e) -> str: flach = None if e.json is None else e.json.get("flach") return ( f"code={e.code} glatt={e.welt.glatt} ordner={e.welt.ordner} schlaf={e.welt.schlaf} " f"gelesen={e.welt.gelesen} meldungen={titel_von(e)} flach={flach} dateien={e.dateien} " f"ausgabe={e.ausgabe[-400:]!r}" ) def instanzordner(pfad: Path, *, wort=TESTWORT, exit_code=0) -> Path: """Ein Wegwerf-Instanzordner mit falschem scripts/killswitch.py und, wenn wort gesetzt ist, killswitch.toml.""" (pfad / "scripts").mkdir(parents=True) (pfad / "config").mkdir() if wort is not None: (pfad / "config" / "killswitch.toml").write_text( f'[killswitch]\nbestaetigungswort = "{wort}"\n', encoding="utf-8" ) (pfad / "scripts" / "killswitch.py").write_text(FALSCHER_KILLSWITCH, encoding="utf-8") (pfad / "exit.txt").write_text(str(exit_code), encoding="utf-8") return pfad def wrapper(tmp: Path, *argumente: str) -> tuple[int, str]: """Der echte abbau_glattstellen.sh aus einem leeren Arbeitsordner; stdout und stderr zusammen.""" leer = tmp / "leer" leer.mkdir(exist_ok=True) lauf = subprocess.run( ["bash", str(WRAPPER), *argumente], cwd=leer, capture_output=True, text=True, timeout=120 ) return lauf.returncode, lauf.stdout + lauf.stderr class FalscherAlpaca: """Ein Broker mit den drei Leseaufrufen; die Betraege sind erfunden und duerfen nie herauskommen.""" def __init__(self, jetzt, fehler=None): self.jetzt = jetzt self.fehler = fehler def marktuhr(self): return SimpleNamespace(is_open=True, next_open=self.jetzt, next_close=self.jetzt + timedelta(minutes=20)) def positionen(self): if self.fehler is not None: raise RuntimeError(self.fehler) return [ SimpleNamespace(symbol="AAPL", menge=10, seite="long", marktwert=1234.56, einstandskurs=98.76), SimpleNamespace(symbol="KO", menge=-20, seite="short", marktwert=-1111.11, einstandskurs=55.55), ] def offene_orders(self): return [SimpleNamespace(id="o-1", menge=10, limit=98.76)] # --- Bedienung und Grundlinie --------------------------------------------------------------------- def test_ohne_modus_bricht_mit_exit_2_ab_und_ruft_nichts(): welt = Welt() codes = [] for argumente in (["--jetzt", JETZT], ["--modus", "probe", "--jetzt", JETZT]): with attrappe(welt), contextlib.redirect_stderr(io.StringIO()): try: ab.main(argumente) except SystemExit as ende: codes.append(ende.code) else: codes.append(None) assert codes == [2, 2], codes assert welt.aufrufe == [], welt.aufrufe def test_grundlinie_je_instanz_genau_ein_eingriff_dann_flach(): e = lauf_mit(Welt()) assert e.code == 0, kurz(e) assert e.welt.glatt == ["alpaca", "ibkr"], kurz(e) assert e.welt.ordner == [ab.ORDNER_WSL["alpaca"], ab.ORDNER_WSL["ibkr"]], kurz(e) assert e.welt.schlaf == [ab.WARTEN_S], kurz(e) assert e.welt.gelesen == {"alpaca": 2, "ibkr": 2}, kurz(e) assert titel_von(e) == ["Abbau Restbestand: flach"], kurz(e) assert e.json["flach"] is True, kurz(e) assert e.json["instanzen"]["alpaca"]["vorher"] == {"positionen": {"AAPL": 10}, "orders": 0}, kurz(e) assert e.json["instanzen"]["ibkr"]["nachher"] == {"positionen": {}, "orders": 0}, kurz(e) assert "Abbau Restbestand (automatisch, Auftrag Pascal 2026-10-07): flach." in e.notiz, e.notiz assert "AAPL long 10" in e.notiz and "NEE long 20" in e.notiz, e.notiz assert "#needs-review" not in e.notiz, e.notiz # --- Fenster --------------------------------------------------------------------------------------- def test_vor_dem_fenster_eine_sekunde_tut_nichts(): e = lauf_mit(Welt(), jetzt="2026-10-08T15:39:59") assert e.code == 0 and e.welt.aufrufe == [] and e.dateien == [], kurz(e) def test_fensterbeginn_genau_1540_greift(): e = lauf_mit(Welt(), jetzt="2026-10-08T15:40:00") assert e.welt.glatt == ["alpaca", "ibkr"], kurz(e) def test_fensterende_genau_2145_am_freitag_greift(): e = lauf_mit(Welt(), jetzt="2026-10-09T21:45:00") assert e.welt.glatt == ["alpaca", "ibkr"], kurz(e) def test_nach_dem_fenster_eine_sekunde_tut_nichts(): e = lauf_mit(Welt(), jetzt="2026-10-09T21:45:01") assert e.code == 0 and e.welt.aufrufe == [] and e.dateien == [], kurz(e) # --- Marke flach und Entwarnung -------------------------------------------------------------------- def test_zweiter_lauf_nach_flach_tut_nichts(): vorher = json.dumps({"zeit": "2026-10-08T15:45:00", "flach": True}) e = lauf_mit(Welt(), ergebnis_vorher=vorher) assert e.code == 0 and e.welt.aufrufe == [] and e.welt.meldungen == [], kurz(e) assert e.ergebnis_text == vorher and e.notiz == "", kurz(e) assert e.dateien == [ERGEBNIS, LOG], kurz(e) def test_ergebnisdatei_ohne_echtes_true_laeuft_weiter(): for vorher in ('{"flach": false}', '{"flach": "ja"}', '{"flach": 1}', "{kaputt", "[true]"): e = lauf_mit(Welt(), ergebnis_vorher=vorher) assert e.welt.glatt == ["alpaca", "ibkr"], (vorher, kurz(e)) def test_erstes_flach_ohne_eingriff_meldet_entwarnung(): vorher = json.dumps({"flach": False, "befunde": ["ibkr: Bestand nachher nicht lesbar; Ergebnis offen"]}) e = lauf_mit(Welt(alpaca=FLACH, ibkr=FLACH), ergebnis_vorher=vorher) assert e.code == 0 and e.welt.glatt == [] and e.welt.schlaf == [], kurz(e) assert titel_von(e) == ["Abbau Restbestand: flach"], kurz(e) assert ": flach." in e.notiz and "#needs-review" not in e.notiz, e.notiz assert e.json["flach"] is True, kurz(e) def test_gesperrte_instanz_verhindert_flach_auch_bei_flachem_bestand(): e = lauf_mit(Welt(units=("inactive", "active"), alpaca=FLACH, ibkr=FLACH)) assert e.code == 1 and e.welt.glatt == [], kurz(e) assert e.json["flach"] is False, kurz(e) assert titel_von(e) == ["Abbau Restbestand: Befund"], kurz(e) assert "**Befund** #needs-review" in e.notiz, e.notiz # --- Modus lesen ----------------------------------------------------------------------------------- def test_modus_lesen_ruft_den_wrapper_nie_und_schreibt_nichts(): e = lauf_mit(Welt(), modus="lesen") assert e.code == 0, kurz(e) assert e.welt.glatt == [] and e.welt.meldungen == [] and e.welt.schlaf == [], kurz(e) assert e.dateien == [], kurz(e) for erwartet in ( "Bestand alpaca: 1 Position(en): AAPL long 10; offene Orders 0", "Glattstellen im Modus scharf: alpaca, ibkr", "ERGEBNIS: gelesen, ohne Befund", ): assert erwartet in e.ausgabe, (erwartet, e.ausgabe) def test_modus_lesen_nachts_bei_markt_zu_liest_beide_bestaende_ohne_eingriff(): e = lauf_mit(Welt(uhr=ZU), modus="lesen", jetzt="2026-10-08T00:30:00") assert e.code == 0, kurz(e) assert e.welt.glatt == [] and e.welt.meldungen == [] and e.dateien == [], kurz(e) assert e.welt.gelesen == {"alpaca": 1, "ibkr": 1}, kurz(e) for erwartet in ( ": ausserhalb", "Runner alpaca (trading-paper.service): LoadState=loaded, ActiveState=inactive", "Markt zu", "Bestand ibkr: 7 Position(en): AAPL long 10, CAT long 10, JPM long 10, KO long 20, MSFT long 10, " "NEE long 20, NVDA long 20; offene Orders 0", "Glattstellen im Modus scharf: keine (Markt zu", ): assert erwartet in e.ausgabe, (erwartet, e.ausgabe) def test_modus_lesen_liest_auch_nach_flach(): e = lauf_mit(Welt(), modus="lesen", ergebnis_vorher='{"flach": true}') assert e.welt.gelesen == {"alpaca": 1, "ibkr": 1} and e.welt.glatt == [], kurz(e) assert "Ergebnisdatei sagt flach" in e.ausgabe, e.ausgabe assert e.dateien == [ERGEBNIS], kurz(e) # --- Marktuhr -------------------------------------------------------------------------------------- def test_markt_zu_scharf_still_ohne_eingriff(): e = lauf_mit(Welt(uhr=ZU)) assert e.code == 0 and e.welt.glatt == [] and e.welt.meldungen == [], kurz(e) assert e.welt.gelesen == {"alpaca": 1, "ibkr": 0}, kurz(e) assert e.dateien == [LOG] and e.notiz == "", kurz(e) assert "Markt zu" in e.log, e.log def test_markt_schliesst_in_599_s_still(): e = lauf_mit(Welt(uhr={"offen": True, "sekunden_bis_schluss": 599})) assert e.code == 0 and e.welt.glatt == [] and e.dateien == [LOG], kurz(e) def test_markt_schliesst_in_genau_600_s_greift(): e = lauf_mit(Welt(uhr={"offen": True, "sekunden_bis_schluss": 600})) assert e.welt.glatt == ["alpaca", "ibkr"], kurz(e) def test_marktuhr_unlesbar_befund_ohne_eingriff(): e = lauf_mit(Welt(uhr=None)) assert e.code == 1 and e.welt.glatt == [] and e.welt.schlaf == [], kurz(e) assert titel_von(e) == ["Abbau Restbestand: Befund"], kurz(e) assert e.json["flach"] is False, kurz(e) assert "**Befund** #needs-review" in e.notiz and "Marktuhr nicht lesbar" in e.notiz, e.notiz # --- Runner-Units ---------------------------------------------------------------------------------- def test_laufender_runner_sperrt_genau_seine_instanz(): e = lauf_mit(Welt(units=("active", "inactive"))) assert e.code == 1 and e.welt.glatt == ["ibkr"], kurz(e) erwartet = "alpaca: Runner-Unit trading-paper.service laeuft (LoadState=loaded, ActiveState=active); nicht angefasst" assert erwartet in e.notiz, e.notiz assert "#needs-review" in e.notiz, e.notiz e = lauf_mit(Welt(units=("inactive", "deactivating"))) assert e.code == 1 and e.welt.glatt == ["alpaca"], kurz(e) def test_runner_zustand_unlesbar_instanz_nicht_angefasst(): for zustand in ("", "unknown", Roh(0, "")): e = lauf_mit(Welt(units=(zustand, "inactive"))) assert e.code == 1 and e.welt.glatt == ["ibkr"], (zustand, kurz(e)) def test_runner_unit_not_found_sperrt_die_instanz(): e = lauf_mit(Welt(units=(("not-found", "inactive"), "inactive"))) assert e.code == 1 and e.welt.glatt == ["ibkr"], kurz(e) assert "LoadState=not-found, ActiveState=inactive" in e.notiz and "#needs-review" in e.notiz, e.notiz def test_runner_failed_gilt_als_stehend(): e = lauf_mit(Welt(units=("failed", "failed"))) assert e.code == 0 and e.welt.glatt == ["alpaca", "ibkr"], kurz(e) # --- Bestaende ------------------------------------------------------------------------------------- def test_ibkr_ohne_ergebnis_zeile_gilt_als_unlesbar(): roh = Roh(1, "Positionen: 1\n AAPL.XNAS 10\nOffene Orders: 0\n") e = lauf_mit(Welt(ibkr=roh)) assert e.code == 1 and e.welt.glatt == ["alpaca"], kurz(e) assert "ibkr: Bestand nicht lesbar" in e.notiz, e.notiz def test_ibkr_exit_4_gilt_als_unlesbar(): e = lauf_mit(Welt(ibkr=Roh(4, ibkr_text(IBKR7)))) assert e.code == 1 and e.welt.glatt == ["alpaca"], kurz(e) def test_ibkr_zahl_und_zeilen_passen_nicht(): roh = Roh(0, "Positionen: 2\n AAPL.XNAS 10\nOffene Orders: 0\nERGEBNIS: gelesen\n") e = lauf_mit(Welt(ibkr=roh)) assert e.code == 1 and e.welt.glatt == ["alpaca"], kurz(e) def test_ibkr_exit_1_mit_ergebnis_gelesen_gilt_als_gelesen(): roh = Roh(1, ibkr_text(IBKR7, schluss="ERGEBNIS: gelesen, mit Befund")) e = lauf_mit(Welt(ibkr=roh)) assert e.code == 0 and e.welt.glatt == ["alpaca", "ibkr"], kurz(e) def test_alpaca_bestand_unlesbar_nicht_angefasst(): e = lauf_mit(Welt(alpaca=None)) assert e.code == 1 and e.welt.glatt == ["ibkr"], kurz(e) assert "alpaca: Bestand nicht lesbar (BrokerFehler: Test); nicht angefasst" in e.notiz, e.notiz def test_fremder_fehlertext_mit_zeilenumbruch_bleibt_einzeilig(): e = lauf_mit(Welt(alpaca=None, alpaca_fehler="BrokerFehler: HTTP 500\n## Zweite Zeile")) assert e.code == 1 and e.welt.glatt == ["ibkr"], kurz(e) assert "BrokerFehler: HTTP 500 ## Zweite Zeile" in e.notiz, e.notiz assert not any(zeile.startswith("## Zweite") for zeile in e.notiz.splitlines()), e.notiz assert not any(zeile.startswith("## Zweite") for zeile in e.log.splitlines()), e.log for titel, text in e.welt.meldungen: assert "\n" not in text and "\r" not in text, text def test_nur_offene_order_ohne_position_wird_glattgestellt(): e = lauf_mit(Welt(alpaca={"positionen": [], "orders": 1}, ibkr=FLACH)) assert e.code == 0 and e.welt.glatt == ["alpaca"], kurz(e) def test_flache_instanz_wird_nicht_angefasst(): e = lauf_mit(Welt(alpaca=FLACH)) assert e.code == 0 and e.welt.glatt == ["ibkr"], kurz(e) assert e.json["instanzen"]["alpaca"]["glattstellen"] is None, kurz(e) # --- Ergebnis nach dem Eingriff -------------------------------------------------------------------- def test_rest_offen_nach_eingriff(): e = lauf_mit(Welt(ibkr_nachher={"positionen": [("NEE.XNYS", 20)], "orders": 0})) assert e.code == 0 and e.json["flach"] is False, kurz(e) assert titel_von(e) == ["Abbau Restbestand: Rest offen"], kurz(e) assert ": Rest offen." in e.notiz and "#needs-review" not in e.notiz, e.notiz def test_offene_order_nachher_ist_nicht_flach(): e = lauf_mit(Welt(alpaca_nachher={"positionen": [], "orders": 1})) assert e.json["flach"] is False, kurz(e) assert titel_von(e) == ["Abbau Restbestand: Rest offen"], kurz(e) def test_wrapper_exit_1_und_danach_flach_heisst_flach_mit_befund(): e = lauf_mit(Welt(wrapper_exit=1)) assert e.code == 1, kurz(e) assert e.welt.glatt == ["alpaca", "ibkr"], kurz(e) assert e.json["flach"] is True, kurz(e) assert titel_von(e) == ["Abbau Restbestand: flach, mit Befund"], kurz(e) assert ": flach, mit Befund #needs-review." in e.notiz, e.notiz assert "alpaca: glattstellen endete mit Exit 1" in e.notiz, e.notiz def test_glattstellen_wirft_exit_none_befund_nachlesen_und_meldung(): e = lauf_mit(Welt(wrapper_wirft=True)) assert e.code == 1 and e.welt.glatt == ["alpaca", "ibkr"], kurz(e) assert e.welt.schlaf == [ab.WARTEN_S] and e.welt.gelesen == {"alpaca": 2, "ibkr": 2}, kurz(e) assert titel_von(e) == ["Abbau Restbestand: Befund"], kurz(e) assert "alpaca: glattstellen endete mit Exit None" in e.notiz, e.notiz assert e.json["instanzen"]["alpaca"]["glattstellen"]["exit"] is None, kurz(e) def test_nachher_unlesbar_ist_befund_und_nicht_flach(): e = lauf_mit(Welt(ibkr_nachher=None)) assert e.code == 1 and e.json["flach"] is False, kurz(e) assert "ibkr: Bestand nachher nicht lesbar" in e.notiz, e.notiz def test_ausnahme_traegt_nur_den_typnamen_in_notiz_und_meldung(): welt = Welt() def wirft(sekunden): raise RuntimeError(f"Order an {KONTO} limit {BETRAG}") welt.schlafe = wirft e = lauf_mit(welt) assert e.code == 1, kurz(e) assert "Befund: abgebrochen: RuntimeError" in e.notiz, e.notiz texte = [e.notiz, e.ergebnis_text or ""] + [titel + " " + text for titel, text in e.welt.meldungen] for text in texte: assert KONTO not in text and BETRAG not in text, text assert "Traceback" in e.log, e.log # --- wsl.exe-Fehler und NUL ------------------------------------------------------------------------ def test_aufruf_nach_wsl_entfernt_nul_und_setzt_wsl_utf8(): gesehen = [] def falscher_start(befehl, **optionen): gesehen.append((befehl, optionen)) return SimpleNamespace( returncode=4294967295, stdout="Es ist keine Distribution".encode("utf-16-le"), stderr="Fehler".encode("utf-16-le"), ) alt = ab.starte ab.starte = falscher_start try: code, ausgabe, fehler = ab.wsl(["systemctl", "show", "trading-paper.service"], 5) finally: ab.starte = alt assert code == 4294967295, code assert NUL not in ausgabe and NUL not in fehler, (ausgabe, fehler) assert ausgabe == "Es ist keine Distribution" and fehler == "Fehler", (ausgabe, fehler) assert len(gesehen) == 1 and gesehen[0][0][0] == ab.WSL, gesehen assert gesehen[0][1]["env"]["WSL_UTF8"] == "1", gesehen[0][1].get("env", {}).get("WSL_UTF8") def test_runner_zustand_mit_nul_kommt_sauber_in_notiz_log_und_meldung(): roh = Roh(0, "LoadState=loaded\nActiveState=E" + NUL + "s" + NUL + "\n") e = lauf_mit(Welt(units=(roh, "inactive"))) assert e.code == 1 and e.welt.glatt == ["ibkr"], kurz(e) assert titel_von(e) == ["Abbau Restbestand: Befund"], kurz(e) assert NUL not in e.notiz and NUL not in e.log, (e.notiz, e.log) welt = Welt() with attrappe(welt): antwort = ab.melde("Abbau Restbestand: Probe" + NUL, "Text" + NUL, ab.SCHARF) assert antwort == "gesendet", antwort assert welt.meldungen == [("Abbau Restbestand: Probe", "Text")], welt.meldungen # --- Abdichtung ------------------------------------------------------------------------------------ def test_glattstellen_und_melden_ohne_scharf_rufen_nie_wsl(): welt = Welt() with attrappe(welt): try: ab.glattstelle("alpaca", ab.LESEN) except RuntimeError: abgelehnt = True else: abgelehnt = False antwort = ab.melde("Abbau Restbestand: Probe", "Text", ab.LESEN) assert abgelehnt, "glattstelle lief im Modus lesen" assert welt.aufrufe == [], welt.aufrufe assert antwort.startswith("nicht gesendet"), antwort def test_abdichtung_alle_pfade_nach_draussen_zeigen_ins_leere(): assert not Path(ab.WSL).exists(), ab.WSL assert ab.starte is subprocess.run, ab.starte for pfad in (ab.WSL_SKRIPT, ab.ALPACA_LESEN, ab.IBKR_LESEN, ab.GLATTSTELLEN, *ab.ORDNER_WSL.values()): assert pfad.startswith("/nicht-vorhanden/"), pfad try: ab.wsl(["systemctl", "show", "trading-paper.service"], 5) except FileNotFoundError: return raise AssertionError("wsl() startete einen Prozess, obwohl WSL ins Leere zeigt") # --- WSL: Wrapper, Kette, Helfer ------------------------------------------------------------------- def test_wsl_wrapper_waehlt_alpaca_ersetzt_das_wort_und_maskiert_konto_und_betraege(): nur_in_wsl() with tempfile.TemporaryDirectory() as tmp: tmp = Path(tmp) ordner = instanzordner(tmp / "alpaca", exit_code=1) code, text = wrapper(tmp, "alpaca", str(ordner)) angekommen = (ordner / "aufgerufen").read_text(encoding="utf-8") assert code == 1, (code, text) assert angekommen == f"--broker alpaca glattstellen --grund {GRUND} --bestaetigung {TESTWORT}", angekommen assert TESTWORT not in text and "[wort]" in text, text assert BETRAG not in text and "Kontowert [x]" in text, text assert KONTO not in text and "Order an [konto]" in text, text assert BETRAG_OHNE_GELDWORT not in text and "limit [x]" in text, text assert "venvs/trading/bin/python" in text, text def test_wsl_wrapper_ibkr_mit_attrappe_nimmt_ihr_venv_und_broker_vor_dem_unterbefehl(): nur_in_wsl() with tempfile.TemporaryDirectory() as tmp: tmp = Path(tmp) ordner = instanzordner(tmp / "ibkr") code, text = wrapper(tmp, "ibkr", str(ordner), "--broker", "attrappe") angekommen = (ordner / "aufgerufen").read_text(encoding="utf-8") assert code == 0, (code, text) assert angekommen.startswith("--broker attrappe glattstellen --grund "), angekommen assert "venvs/trading-ibkr/bin/python" in text, text def test_wsl_wrapper_lehnt_anderen_broker_und_fremde_angaben_ab(): nur_in_wsl() with tempfile.TemporaryDirectory() as tmp: tmp = Path(tmp) ordner = instanzordner(tmp / "alpaca") faelle = ( ("alpaca", str(ordner), "--broker", "alpaca"), ("alpaca", str(ordner), "--broker"), ("alpaca", str(ordner), "--weiter"), ("live", str(ordner)), ("alpaca",), ) codes = [wrapper(tmp, *fall)[0] for fall in faelle] aufgerufen = (ordner / "aufgerufen").exists() assert codes == [2, 2, 2, 2, 2], codes assert not aufgerufen, "killswitch.py wurde trotz Bedienfehler gerufen" def test_wsl_wrapper_ohne_wort_ruft_killswitch_nicht(): nur_in_wsl() with tempfile.TemporaryDirectory() as tmp: tmp = Path(tmp) ordner = instanzordner(tmp / "alpaca", wort=None) code, text = wrapper(tmp, "alpaca", str(ordner)) aufgerufen = (ordner / "aufgerufen").exists() assert code == 4 and not aufgerufen, (code, aufgerufen, text) def test_wsl_kette_wort_konto_und_betrag_stehen_nirgends(): nur_in_wsl() with tempfile.TemporaryDirectory() as tmp: tmp = Path(tmp) ordner = {instanz: instanzordner(tmp / instanz, exit_code=1) for instanz in ab.INSTANZEN} def echter_wrapper(argumente): assert Path(argumente[1]).resolve().is_relative_to(tmp.resolve()), argumente code, text = wrapper(tmp, *argumente) return code, text, "" alt = ab.ORDNER_WSL ab.ORDNER_WSL = {instanz: str(pfad) for instanz, pfad in ordner.items()} try: e = lauf_mit(Welt(echter_wrapper=echter_wrapper)) finally: ab.ORDNER_WSL = alt angekommen = (ordner["ibkr"] / "aufgerufen").read_text(encoding="utf-8") assert e.code == 1 and e.welt.glatt == ["alpaca", "ibkr"], kurz(e) assert angekommen.startswith("--broker ibkr glattstellen --grund "), angekommen assert angekommen.endswith("--bestaetigung " + TESTWORT), angekommen assert "[wort]" in e.ergebnis_text and "Kontowert [x]" in e.notiz, (e.ergebnis_text, e.notiz) assert "[konto]" in e.notiz, e.notiz texte = [e.ausgabe, e.ergebnis_text, e.log, e.notiz] + [titel + " " + text for titel, text in e.welt.meldungen] for text in texte: for geheim in (TESTWORT, KONTO, BETRAG, BETRAG_OHNE_GELDWORT): assert geheim not in text, (geheim, text) def test_wsl_alpaca_helfer_gibt_stueck_ohne_betrag(): nur_in_wsl() import abbau_alpaca_lesen as helfer jetzt = datetime(2026, 10, 8, 14, 0, tzinfo=timezone.utc) daten = helfer.lies(FalscherAlpaca(jetzt), jetzt) text = json.dumps(daten) assert daten["uhr"] == {"offen": True, "sekunden_bis_schluss": 1200}, daten assert daten["positionen"] == [{"symbol": "AAPL", "menge": 10}, {"symbol": "KO", "menge": -20}], daten assert daten["orders"] == 1 and daten["bestand_fehler"] is None, daten for betrag in ("1234.56", "98.76", "1111.11", "55.55"): assert betrag not in text, (betrag, text) def test_wsl_alpaca_helfer_maskiert_konto_und_betraege_im_fehlertext(): nur_in_wsl() import abbau_alpaca_lesen as helfer jetzt = datetime(2026, 10, 8, 14, 0, tzinfo=timezone.utc) fehler = f"Order an {KONTO}: limit {BETRAG_OHNE_GELDWORT}, Kontowert {BETRAG} USD" daten = helfer.lies(FalscherAlpaca(jetzt, fehler=fehler), jetzt) text = json.dumps(daten) assert daten["positionen"] is None and daten["orders"] is None, daten assert daten["bestand_fehler"].startswith("RuntimeError: "), daten assert "Order an [konto]" in daten["bestand_fehler"] and "limit [x]" in daten["bestand_fehler"], daten for geheim in (KONTO, BETRAG, BETRAG_OHNE_GELDWORT): assert geheim not in text, (geheim, text) assert daten["uhr"] == {"offen": True, "sekunden_bis_schluss": 1200}, daten def test_wsl_ibkr_lesen_ohne_erreichbaren_ordner_startet_nichts(): nur_in_wsl() with tempfile.TemporaryDirectory() as tmp: leer = Path(tmp) ohne = subprocess.run(["bash", str(IBKR_SKRIPT)], cwd=leer, capture_output=True, text=True, timeout=60) fehlt = subprocess.run( ["bash", str(IBKR_SKRIPT), "/nicht-vorhanden/ibkr"], cwd=leer, capture_output=True, text=True, timeout=60 ) assert ohne.returncode == 2, (ohne.returncode, ohne.stderr) assert fehlt.returncode == 4, (fehlt.returncode, fehlt.stderr) # --- Lauf als Skript ------------------------------------------------------------------------------- def main() -> int: faelle = [(name, wert) for name, wert in globals().items() if name.startswith("test_") and callable(wert)] abweichungen = 0 uebersprungen = 0 for name, fall in faelle: try: fall() except Uebersprungen as grund: uebersprungen += 1 print(f"UEBERSPRUNGEN {name}: {grund}") continue except (Exception, SystemExit) as ausnahme: abweichungen += 1 print(f"FEHL {name}: {type(ausnahme).__name__}: {str(ausnahme)[:400]!r}") continue print(f"OK {name}") print(f"ERGEBNIS: {len(faelle)} Faelle, {abweichungen} Abweichung(en), {uebersprungen} uebersprungen") return 1 if abweichungen else 0 if __name__ == "__main__": sys.exit(main())