diff --git a/.vault-tools/trading-plattform-pruefung/LIESMICH.md b/.vault-tools/trading-plattform-pruefung/LIESMICH.md index b7aa683..baded44 100644 --- a/.vault-tools/trading-plattform-pruefung/LIESMICH.md +++ b/.vault-tools/trading-plattform-pruefung/LIESMICH.md @@ -5,7 +5,7 @@ title: "Trading Plattform: Prüfwerkzeuge des Koordinators fürs Dashboard" summary: "Eigener Lauf mit echten Daten zum Dashboard der Trading Plattform — Vergleich der Endpunkte gegen Vollparse und pandas, zwei WebSocket-Verbindungen, Browser-Lauf und Wiederaufbau mit Playwright, gefahrlose Proben der zwei Skripte, Mutantenläufe. Entstanden 2026-09-17 (Teil 3b), erweitert 2026-09-18 (Teil 10: Anmeldung, Angriffsfälle, Beobachtung, Kerzenlatenz), bewusst nicht im Projekt-Repo." tags: [vault-tools, trading-plattform] created: 2026-09-17 -updated: 2026-09-24 +updated: 2026-10-08 --- # Prüfwerkzeuge fürs Dashboard der Trading Plattform @@ -179,3 +179,18 @@ Nach dem Umzug auf den Docker-Host (Hyper-V-VM) prüft der Host selbst: `deploy/ | `abendkontrolle_ibkr_python_angriffe.py` | seit der Prüfung vom 2026-09-26 mit zweiter Sperre: `IBKR_ORDNER` zeigt ins Leere; dazu dichtet `rueckweg_aapl_angriffe.py` seither `WSL` und `WSL_SKRIPT` im Modul `rueckweg_aapl` ab, und `abendkontrolle_angriffe.py` räumt seinen leeren Ordner im Temp wieder weg. Sonst: vier Fälle ohne Netz für `--ibkr-python` von `abendkontrolle_wsl.py`: Schalter, Vorgabe `IBKR_PY`, `ibkr_pruefen.py lesen` läuft mit dem genannten Interpreter (Ersatzskript im Wegwerf-Ordner), der Ablauf reicht den Schalter weiter | `python abendkontrolle_ibkr_python_angriffe.py` in WSL im Venv | Was es nicht tut: Es prüft nicht, ob der Host die Kontrolle überhaupt fährt — bleibt sie dort aus, steht jeden Abend „noch kein Ergebnis“ in der Notiz, und das ist der Hinweis. Steht `-QuelleIbkr` falsch, fällt das nicht immer auf: Prüft die Aufgabe den Teil ibkr in WSL, obwohl die Instanz schon auf dem Host läuft, meldet sie ihn dort nur leise als „nicht gestartet“, weil der Zeitgeber in WSL aus ist. Ein Anmelde-Lauf zwischen 22:30 und dem Ende der Prüfung auf dem Host bekommt einmal „noch kein Ergebnis“, weil die Frist `FRUEHESTENS` für beide Quellen gilt (die Mutanten suchen genau diese Zeile). + +## Paket AB (2026-10-07): Abbau des Restbestands nach dem Netzschalter-Abriss + +Am 2026-10-07 um 20:13 beendete ein Druck auf den Netzschalter beide Paper-Runner mitten im Handel, ohne Glattstellen. Pascal am selben Abend: am 2026-10-08 soweit es geht alles verkaufen, Testläufe bis Dienstag gestoppt. Die Zeitgeber `trading-paper.timer` und `trading-paper-ibkr.timer` sind seitdem aus, die Abendkontrolle ebenfalls; die Meldung dieses Werkzeugs ist in der Pause das einzige Netz. Plan im Projekt: `docs/plans/2026-10-07-paket-ab-abbau-restbestand/`. + +| Datei | Was sie tut | Aufruf | +|---|---|---| +| `abbau_restbestand.py` | Windows-Seite. Im Modus `scharf` nur zwischen 2026-10-08 15:40 und 2026-10-09 21:45: Runner-Units über `systemctl show` (nur `LoadState=loaded` mit `inactive` oder `failed` gilt als stehend), Marktuhr, beide Bestände; je Instanz mit Position oder offener Order genau ein Glattstellen über `abbau_glattstellen.sh`; nach 30 s beide Bestände neu; Ergebnis in `data/paper/abbau/abbau.json` und `abbau.log` im Projekt, mit `"flach": true` enden alle späteren Läufe still; bei Eingriff, Befund oder dem ersten "flach" eine Zeile in der Tagesnotiz und eine Meldung mit hoher Priorität. Fail-closed: laufender oder nicht geladener Runner, unlesbarer Zustand, unlesbare Marktuhr oder unlesbarer Bestand heißt nicht angefasst, mit Befund; Markt zu oder weniger als 10 min bis Schluss heißt nichts, ohne Meldung. `wsl()` setzt `WSL_UTF8=1` und entfernt NUL; Befunde aus Ausnahmen nennen nur den Typ, der Traceback steht nur in `abbau.log`. `--modus lesen` fährt nur die lesenden Aufrufe, auch außerhalb des Fensters, und schreibt nichts; ohne `--modus` Exit 2 | `python abbau_restbestand.py --modus lesen`; `scharf` nur über die Aufgabe | +| `abbau_glattstellen.sh` | WSL. Liest das Bestätigungswort aus `config/killswitch.toml` der Instanz, wählt den Broker ausdrücklich (`--broker alpaca` oder `--broker ibkr`) und ruft genau einmal `scripts/killswitch.py glattstellen` im Instanzordner mit deren Venv. Die Ausgabe geht durch einen Filter: das Wort wird `[wort]`, dann `ohne_kontonummer()` (Kontonummern als `[konto]`, dann `maskiere()`), dann jede Dezimalzahl `[x]`. Kennt keinen echten Ordner, der Aufrufer nennt ihn. `--broker attrappe` ersetzt die Wahl, nur für die Probe in einer Wegwerfkopie | `bash abbau_glattstellen.sh [--broker attrappe]` in WSL, Instanz `alpaca` oder `ibkr` | +| `abbau_alpaca_lesen.py` | WSL, Venv `~/venvs/trading`: Marktuhr, Positionen (nur Symbol und Stück) und Zahl der offenen Orders des Alpaca-Kontos als eine JSON-Zeile; Fehlertexte durch `ohne_kontonummer()` und die Dezimalmaske | nur über `abbau_restbestand.py`; von Hand `python abbau_alpaca_lesen.py --projekt ` | +| `abbau_ibkr_lesen.sh` | WSL: `scripts/ibkr_pruefen.py lesen` im IBKR-Ordner mit `~/venvs/trading-ibkr` (Client-ID 12, lesend), nie `orderweg` | nur über `abbau_restbestand.py` | +| `abbau_restbestand_angriffe.py` | Angriffsfälle als Funktionen `test_*`, lauffähig als Skript und unter pytest (für `mutanten_lauf.py`). Abgedichtet: alle Pfade nach draußen zeigen ins Leere, `wsl()` ist durch eine zählende Attrappe ersetzt, im Fall zu `wsl()` selbst auch `subprocess.run`. Die Fälle `test_wsl_*` fahren den echten Wrapper und die Venvs gegen Wegwerf-Instanzordner mit falschem `killswitch.py`, erfundenem Wort, erfundener Kontonummer und erfundenen Beträgen; unter Windows übersprungen | unter Windows `python -I -X utf8 abbau_restbestand_angriffe.py`, in WSL `~/venvs/trading/bin/python -I abbau_restbestand_angriffe.py`; Mutanten: `mutanten.toml` im Planordner | +| `abbau_restbestand_einrichten.ps1` | Aufgabe "Trading Plattform - Abbau Restbestand": Do 2026-10-08 15:45, 16:15, 17:00, 19:00, 21:00, Ersatz Fr 2026-10-09 15:45, 16:30, 19:00 und fünf Minuten nach jeder Anmeldung; nur angemeldet, Zeitlimit 45 min, keine zweite Instanz; druckt nach dem Registrieren die Einstellungen | `powershell -NoProfile -File abbau_restbestand_einrichten.ps1 [-Aktion entfernen]` | + +Was es nicht tut: Es gibt keinen Killswitch frei. Jede angefasste Instanz steht danach auf `gesperrt`, Freigeben bleibt Pascal (`scripts/killswitch.py freigeben`). Es hat keinen eigenen Ordercode und keine Sperrdatei gegen einen zweiten Lauf von Hand; die Aufgabe selbst startet keine zweite Instanz. Während des Aufrufs steht das Wort in WSL in der Prozessliste von `killswitch.py`, dessen Schnittstelle es als Argument nimmt. Ob `wsl.exe --exec` eine Meldung mit Leerzeichen so weitergibt wie ein Skript mit Argumenten, war vor dem ersten scharfen Lauf nicht gemessen; was die Meldung ergab, steht in der Notizzeile unter "Meldung ans Telefon". Nach dem 2026-10-09 wird die Aufgabe mit `-Aktion entfernen` abgebaut. diff --git a/.vault-tools/trading-plattform-pruefung/abbau_alpaca_lesen.py b/.vault-tools/trading-plattform-pruefung/abbau_alpaca_lesen.py new file mode 100644 index 0000000..b47399f --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/abbau_alpaca_lesen.py @@ -0,0 +1,101 @@ +"""Abbau Restbestand: liest Marktuhr, Positionen und offene Orders des Alpaca-Paper-Kontos, nur lesend. + +Ersteller: Pascal Michel +Erstellt: 2026-10-07 +Doku: LIESMICH.md in diesem Ordner, Abschnitt "Paket AB" + +Laeuft in WSL mit dem Venv des Alpaca-Ordners (~/venvs/trading); gestartet von abbau_restbestand.py +ueber wsl.exe --exec. Ruft nur marktuhr(), positionen() und offene_orders() von AlpacaBroker +(trading/broker/alpaca.py: GET /v2/clock, /v2/positions, /v2/orders?status=open). Nichts davon +schreibt beim Broker oder auf der Platte. +Ausgabe: genau eine JSON-Zeile auf stdout mit + uhr {"offen": bool, "sekunden_bis_schluss": int} oder null + uhr_fehler Text oder null + positionen [{"symbol": str, "menge": int}] oder null; menge ist negativ bei short + orders Zahl der offenen Orders oder null + bestand_fehler Text oder null +Nie ein Betrag: Von einer Position gehen nur Symbol und Stueckzahl weiter, von den Orders nur die +Zahl. Fehlertexte laufen durch ohne_kontonummer() aus abendkontrolle_wsl.py (Kontonummern, dann +maskiere()) und danach durch DEZIMAL: jede Dezimalzahl wird [x], ganze Zahlen bleiben. +Den Projektordner nennt der Aufrufer (--projekt); hier steht kein echter Pfad. +Aufruf: python abbau_alpaca_lesen.py --projekt +Exit: 0 mit der JSON-Zeile (auch wenn darin Fehler stehen), 2 Bedienfehler. +""" + +import argparse +import json +import os +import re +import sys +from datetime import datetime +from datetime import timezone +from pathlib import Path + +HIER = Path(__file__).resolve().parent +sys.path.insert(0, str(HIER)) +from abendkontrolle_wsl import ohne_kontonummer # noqa: E402 + +GEHEIMNISSE = "/home/claude/.config/trading-plattform/secrets.toml" +RISIKO = "config/risk.toml" +FEHLERTEXT_MAX = 200 +# Jede Dezimalzahl, auch ohne Geldwort davor; maskiere() faengt nur Zahlen hinter Geldwoertern, +# vor Waehrungen oder mit vielen Stellen. +DEZIMAL = re.compile(r"-?\d+[.,]\d+") + + +def fehlertext(ausnahme: BaseException) -> str: + """Typ und Text einer Ausnahme: Kontonummern als [konto], Betraege als [x], gekuerzt.""" + text = ohne_kontonummer(f"{type(ausnahme).__name__}: {ausnahme}") + return DEZIMAL.sub("[x]", text)[:FEHLERTEXT_MAX] + + +def lies(broker, jetzt: datetime) -> dict: + """Marktuhr und Bestand; jede Frage fuer sich, ein Fehler in der einen haelt die andere nicht auf.""" + ergebnis = {"uhr": None, "uhr_fehler": None, "positionen": None, "orders": None, "bestand_fehler": None} + try: + uhr = broker.marktuhr() + ergebnis["uhr"] = { + "offen": bool(uhr.is_open), + "sekunden_bis_schluss": int((uhr.next_close - jetzt).total_seconds()), + } + except Exception as ausnahme: + ergebnis["uhr_fehler"] = fehlertext(ausnahme) + try: + positionen = broker.positionen() + orders = broker.offene_orders() + ergebnis["positionen"] = [ + {"symbol": position.symbol, "menge": int(position.menge)} for position in positionen + ] + ergebnis["orders"] = len(orders) + except Exception as ausnahme: + ergebnis["positionen"] = None + ergebnis["orders"] = None + ergebnis["bestand_fehler"] = fehlertext(ausnahme) + return ergebnis + + +def main(argv=None) -> int: + zerleger = argparse.ArgumentParser(description=__doc__.splitlines()[0], allow_abbrev=False) + zerleger.add_argument("--projekt", required=True, help="Ordner der Alpaca-Instanz in WSL") + argumente = zerleger.parse_args(argv) + os.environ.setdefault("TRADING_GEHEIMNISSE", GEHEIMNISSE) + try: + os.chdir(argumente.projekt) + sys.path.insert(0, str(Path(argumente.projekt).resolve())) + from trading.broker.alpaca import AlpacaBroker + from trading.risk.limits import lade_limits + + broker = AlpacaBroker.from_secrets(None, universum=lade_limits(RISIKO).universum) + except Exception as ausnahme: + grund = fehlertext(ausnahme) + print(json.dumps( + {"uhr": None, "uhr_fehler": grund, "positionen": None, "orders": None, "bestand_fehler": grund}, + ensure_ascii=False, + )) + return 0 + print(json.dumps(lies(broker, datetime.now(timezone.utc)), ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/abbau_glattstellen.sh b/.vault-tools/trading-plattform-pruefung/abbau_glattstellen.sh new file mode 100644 index 0000000..e6336a1 --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/abbau_glattstellen.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Abbau Restbestand: stellt eine Paper-Instanz ueber scripts/killswitch.py glattstellen glatt. +# Ersteller: Pascal Michel Erstellt: 2026-10-07 +# Doku: LIESMICH.md in diesem Ordner, Abschnitt "Paket AB" +# +# Das Bestaetigungswort verlaesst WSL nie: Dieses Skript liest es aus config/killswitch.toml der +# Instanz und reicht es nur an killswitch.py weiter. Jede Zeile von killswitch.py geht danach durch +# einen Filter: das Wort wird [wort], dann ohne_kontonummer() aus abendkontrolle_wsl.py +# (Kontonummern als [konto], dann maskiere()), dann jede Dezimalzahl als [x]. +# Den Broker waehlt das Skript ausdruecklich: alpaca -> --broker alpaca, ibkr -> --broker ibkr +# (gemessen: ibkr ist im IBKR-Ordner mit Marke ibkr erlaubt, alpaca im Projektordner ohne Marke). +# --broker attrappe ersetzt die Wahl, nur fuer die Probe in einer Wegwerfkopie und die Angriffsfaelle. +# Den Instanzordner nennt immer der Aufrufer (abbau_restbestand.py); hier steht kein echter Pfad. +# Genau ein Aufruf von killswitch.py je Start, keine Schleife. +# +# Aufruf: bash abbau_glattstellen.sh [--broker attrappe] +# Exit: der von killswitch.py (0 erfolgreich, 1 nicht erfolgreich, 3 abgelehnt, 4 Voraussetzung), +# 2 Bedienfehler, 4 Wort nicht lesbar oder Instanzordner nicht erreichbar. +set -u + +GRUND="Abbau Restbestand nach Netzschalter-Abriss 2026-10-07; Auftrag Pascal 2026-10-07, ausgefuehrt von der Aufgabe Abbau Restbestand" +HIER="$(dirname "$(readlink -f "$0")")" +export TRADING_GEHEIMNISSE=/home/claude/.config/trading-plattform/secrets.toml +export PYTHONIOENCODING=utf-8 + +if [ "$#" -lt 2 ]; then + echo "Bedienfehler: Aufruf abbau_glattstellen.sh [--broker attrappe]" >&2 + exit 2 +fi +instanz="$1" +ordner="$2" +shift 2 +case "$instanz" in + alpaca) + py=/home/claude/venvs/trading/bin/python + broker=(--broker alpaca) + ;; + ibkr) + py=/home/claude/venvs/trading-ibkr/bin/python + broker=(--broker ibkr) + ;; + *) + echo "Bedienfehler: Instanz muss alpaca oder ibkr sein" >&2 + exit 2 + ;; +esac +while [ "$#" -gt 0 ]; do + case "$1" in + --broker) + if [ "$#" -lt 2 ] || [ "$2" != "attrappe" ]; then + echo "Bedienfehler: --broker nimmt nur attrappe" >&2 + exit 2 + fi + broker=(--broker attrappe) + shift 2 + ;; + *) + echo "Bedienfehler: unbekannte Angabe" >&2 + exit 2 + ;; + esac +done + +if ! cd "$ordner"; then + echo "Voraussetzung fehlt: Instanzordner nicht erreichbar" >&2 + exit 4 +fi + +# Das Wort, nur in dieser Shell; tomllib aus dem Python der Instanz. +wort="$("$py" -I -c 'import tomllib +with open("config/killswitch.toml", "rb") as datei: + print(tomllib.load(datei)["killswitch"]["bestaetigungswort"])' 2>/dev/null)" +if [ -z "$wort" ]; then + echo "Voraussetzung fehlt: Bestaetigungswort in config/killswitch.toml nicht lesbar" >&2 + exit 4 +fi + +# Genau ein Aufruf; stdout und stderr zusammen, gefiltert erst hinter dem Lauf. +ausgabe="$("$py" scripts/killswitch.py "${broker[@]}" glattstellen --grund "$GRUND" --bestaetigung "$wort" 2>&1)" +code=$? + +# Filter je Zeile: Wort, Kontonummern und maskiere(), Dezimalzahlen. Das Wort geht nur ueber die Umgebung. +if ! printf '%s\n' "$ausgabe" | ABBAU_WORT="$wort" "$py" -I -c 'import os +import re +import sys +sys.path.insert(0, sys.argv[1]) +from abendkontrolle_wsl import ohne_kontonummer +wort = os.environ.pop("ABBAU_WORT", "") +for zeile in sys.stdin.read().splitlines(): + if wort: + zeile = zeile.replace(wort, "[wort]") + zeile = ohne_kontonummer(zeile) + print(re.sub(r"-?\d+[.,]\d+", "[x]", zeile)[:300])' "$HIER"; then + echo "Ausgabe von killswitch.py nicht gefiltert und deshalb zurueckgehalten" +fi +unset wort ausgabe +exit "$code" diff --git a/.vault-tools/trading-plattform-pruefung/abbau_ibkr_lesen.sh b/.vault-tools/trading-plattform-pruefung/abbau_ibkr_lesen.sh new file mode 100644 index 0000000..341a6cf --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/abbau_ibkr_lesen.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Abbau Restbestand: liest Positionen und offene Orders der IBKR-Instanz, nur lesend (ibkr_pruefen.py lesen). +# Ersteller: Pascal Michel Erstellt: 2026-10-07 +# Doku: LIESMICH.md in diesem Ordner, Abschnitt "Paket AB" +# +# Faehrt scripts/ibkr_pruefen.py lesen im Instanzordner mit ~/venvs/trading-ibkr (Client-ID 12, +# lesend); den Unterbefehl orderweg ruft es nie. Den Instanzordner nennt der Aufrufer +# (abbau_restbestand.py); hier steht kein echter Pfad. +# Aufruf: bash abbau_ibkr_lesen.sh +# Exit: der von ibkr_pruefen.py (0 gelesen, 1 Befund, 4 Voraussetzung fehlt), 2 Bedienfehler, +# 4 Instanzordner nicht erreichbar. +set -u + +if [ "$#" -ne 1 ]; then + echo "Bedienfehler: Aufruf abbau_ibkr_lesen.sh " >&2 + exit 2 +fi +export TRADING_GEHEIMNISSE=/home/claude/.config/trading-plattform/secrets.toml +export PYTHONIOENCODING=utf-8 +if ! cd "$1"; then + echo "Voraussetzung fehlt: Instanzordner nicht erreichbar" >&2 + exit 4 +fi +exec /home/claude/venvs/trading-ibkr/bin/python scripts/ibkr_pruefen.py lesen diff --git a/.vault-tools/trading-plattform-pruefung/abbau_restbestand.py b/.vault-tools/trading-plattform-pruefung/abbau_restbestand.py new file mode 100644 index 0000000..5f968df --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/abbau_restbestand.py @@ -0,0 +1,575 @@ +"""Abbau Restbestand: stellt am 2026-10-08 beide Paper-Konten ueber den Notweg glatt, Ersatztag 2026-10-09. + +Ersteller: Pascal Michel +Erstellt: 2026-10-07 +Doku: LIESMICH.md in diesem Ordner, Abschnitt "Paket AB" + +Koordinator-Werkzeug, nicht fuers Repo. Am 2026-10-07 um 20:13 beendete ein Druck auf den +Netzschalter beide Paper-Runner mitten im Handel, ohne Glattstellen. Pascal am selben Abend: +morgen soweit es geht alles verkaufen, Testlaeufe bis Dienstag gestoppt. Plan im Projekt: +docs/plans/2026-10-07-paket-ab-abbau-restbestand/. Gestartet von der Windows-Aufgabe +"Trading Plattform - Abbau Restbestand" (abbau_restbestand_einrichten.ps1) mit --modus scharf. + +Ablauf im Modus scharf, jeder Schritt mit seinem Grund: + 1. Nur zwischen FRUEHESTENS und SPAETESTENS (Uhr dieses Rechners); sonst nichts, auch keine Datei. + Ergebnisdatei mit "flach": true -> nichts mehr, nur eine Zeile im Log. + 2. Runner-Units (systemctl show, LoadState und ActiveState): Laeuft eine, ist sie nicht geladen + oder ihr Zustand nicht lesbar, wird diese Instanz nicht angefasst (Befund). systemctl is-active + meldete auch fuer eine Unit, die es nicht gibt, inactive. Die Zeitgeber sind seit 2026-10-07 aus. + 3. Marktuhr von Alpaca (abbau_alpaca_lesen.py): zu oder weniger als MIN_BIS_SCHLUSS_S bis zum + Schluss -> nichts, nur eine Zeile im Log. Nicht lesbar -> Befund, nichts angefasst. + 4. Bestaende beider Instanzen; nicht lesbar -> Befund, diese Instanz nicht angefasst. + 5. Je Instanz mit Position oder offener Order genau ein Aufruf von abbau_glattstellen.sh + (scripts/killswitch.py glattstellen; das Bestaetigungswort bleibt in WSL). Keine Schleife: + Der naechste Termin der Aufgabe ist die Wiederholung. Wirft der Aufruf, gilt Exit None. + 6. Nach einem Eingriff WARTEN_S warten und beide Bestaende neu lesen. + 7. Ergebnisdatei abbau.json; "flach": true nur, wenn beide Instanzen nachher ohne Position und + ohne offene Order gelesen sind und keine Instanz gesperrt ist. Sonst beobachtet der naechste + Termin weiter. + 8. Bei Eingriff, Befund oder dem ersten "flach" eine Zeile in Inbox/.md und eine Meldung mit + hoher Prioritaet (abendkontrolle_wsl.py --meldung). Die Abendkontrolle ist in der Pause aus; + diese Meldung ist das einzige Netz. +Modus lesen: nur die lesenden Aufrufe, ohne Fenster und ohne die Sperre durch "flach"; kein +Glattstellen, keine Meldung, keine Notiz, keine Datei. Das Ergebnis steht auf stdout. +Jeder Aufruf nach draussen geht ueber wsl(); die Angriffsfaelle ersetzen sie. wsl.exe schreibt eigene +Fehler in UTF-16LE: wsl() setzt WSL_UTF8=1 und entfernt NUL, sonst scheiterte die Meldung an einem +NUL im Argument. Ausgabe und Befunde sind einzeilig: Ein fremder Fehlertext mit Zeilenumbruch +setzte sonst eigene Zeilen, bis hin zu einer Ueberschrift, in die Tagesnotiz. Befunde aus Ausnahmen +nennen nur den Typ; der Traceback steht nur in abbau.log. +Annahme, benannt wie in abendkontrolle.py: Die Uhr dieses Rechners geht nach Berliner Zeit. +Aufruf: python abbau_restbestand.py --modus scharf|lesen [--jetzt ISO] [--projekt PFAD] [--vault PFAD] +Exit: 0 ohne Befund, 1 mit Befund, 2 Bedienfehler. +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import time +import traceback +from datetime import datetime +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from abendkontrolle import KEIN_FENSTER # noqa: E402 +from abendkontrolle import WSL # noqa: E402 +from abendkontrolle import WSL_PY # noqa: E402 +from abendkontrolle import WSL_SKRIPT # noqa: E402 +from abendkontrolle import schreibe_notiz # noqa: E402 + +VAULT = Path(r"D:\Claude") +PROJEKT = VAULT / "Code Projekte" / "active - Trading Plattform" +ABLAGE_TEIL = Path("data") / "paper" / "abbau" +ERGEBNIS_NAME = "abbau.json" +LOG_NAME = "abbau.log" +# Entscheidung 4 der Anforderung, Berliner Ortszeit, beide Grenzen eingeschlossen. +FRUEHESTENS = datetime(2026, 10, 8, 15, 40) +SPAETESTENS = datetime(2026, 10, 9, 21, 45) +MIN_BIS_SCHLUSS_S = 600 +WARTEN_S = 30 +SCHARF = "scharf" +LESEN = "lesen" +MODI = (SCHARF, LESEN) +NICHTS = "nichts" +STILL = "still" +LESEN_ENDE = "lesen_ende" +BERICHT = "bericht" +INSTANZEN = ("alpaca", "ibkr") +UNITS = {"alpaca": "trading-paper.service", "ibkr": "trading-paper-ibkr.service"} +# Die Instanzordner in WSL. Nur dieses Skript nennt sie; die WSL-Teile bekommen sie als Argument. +ORDNER_WSL = { + "alpaca": "/mnt/d/Claude/Code Projekte/active - Trading Plattform", + "ibkr": "/mnt/d/Claude/.arbeitsbaeume/trading-ibkr-betrieb", +} +HIER_WSL = "/mnt/d/Claude/.vault-tools/trading-plattform-pruefung" +ALPACA_PY = "/home/claude/venvs/trading/bin/python" +ALPACA_LESEN = HIER_WSL + "/abbau_alpaca_lesen.py" +IBKR_LESEN = HIER_WSL + "/abbau_ibkr_lesen.sh" +GLATTSTELLEN = HIER_WSL + "/abbau_glattstellen.sh" +RUNNER_LAEUFT = ("active", "activating", "deactivating", "reloading", "refreshing") +RUNNER_STEHT = ("inactive", "failed") +RUNNER_TIMEOUT_S = 60 +LESEN_TIMEOUT_S = 180 +# killswitch.py: Gateway-Zeitlimit 10 s je Anfrage, bis zu drei Sammelversuche, 20 s Positionsfrist. +GLATT_TIMEOUT_S = 600 +MELDUNG_TIMEOUT_S = 120 +ZEILEN_MAX = 12 +ZEILE_MAX = 200 +# CREATE_NO_WINDOW gibt es nur unter Windows; die Angriffsfaelle laufen auch in WSL. +FENSTERLOS = KEIN_FENSTER if sys.platform == "win32" else 0 +NUL = chr(0) +# Die Ausgabe von scripts/ibkr_pruefen.py lesen, gemessen am 2026-10-07 gegen 22:56 (Anforderung). +IBKR_POSITIONEN = re.compile(r"^Positionen: (\d+)\s*$") +IBKR_ORDERS = re.compile(r"^Offene Orders: (\d+)\s*$") +IBKR_ZEILE = re.compile(r"^ (\S+) (-?\d+)(?:\.0+)?\s*$") +IBKR_GELESEN = "ERGEBNIS: gelesen" +TITEL = "Abbau Restbestand" +KOPF = "Abbau Restbestand (automatisch, Auftrag Pascal 2026-10-07)" +GESPERRT_HINWEIS = "Der Killswitch jeder angefassten Instanz steht danach auf gesperrt; Freigeben bleibt Pascal." + +# Die Angriffsfaelle ersetzen schlafe und starte, damit kein Fall wartet und keiner einen Prozess startet. +schlafe = time.sleep +starte = subprocess.run + + +def sauber(text: str) -> str: + """Text ohne NUL: wsl.exe schreibt eigene Fehler in UTF-16LE, und ein NUL im Argument laesst subprocess scheitern.""" + return text.replace(NUL, "") + + +def einzeilig(text: str) -> str: + """Ohne NUL und ohne Zeilenumbruch: Ein fremder Text bleibt eine Zeile in Ausgabe, Log, Notiz und Meldung.""" + return sauber(text).replace("\r", " ").replace("\n", " ") + + +def wsl(argumente: list[str], timeout: int) -> tuple[int, str, str]: + """Ein Aufruf in WSL ohne Shell (--exec): Exit, stdout, stderr. Der einzige Weg nach draussen.""" + lauf = starte( + [WSL, "-d", "Debian", "-u", "claude", "--exec", *argumente], + capture_output=True, timeout=timeout, creationflags=FENSTERLOS, + env={**os.environ, "WSL_UTF8": "1"}, + ) + return ( + lauf.returncode, + sauber(lauf.stdout.decode("utf-8", errors="replace")), + sauber(lauf.stderr.decode("utf-8", errors="replace")), + ) + + +def lies_runner(instanz: str) -> str: + """LoadState und ActiveState der Runner-Unit (systemctl show); liest nur.""" + _, ausgabe, _ = wsl(["systemctl", "show", "--property=LoadState,ActiveState", UNITS[instanz]], RUNNER_TIMEOUT_S) + return ausgabe + + +def runner_felder(ausgabe: str) -> tuple[str, str]: + """LoadState und ActiveState aus der Ausgabe von systemctl show; was fehlt, ist leer.""" + felder = {} + for zeile in ausgabe.splitlines(): + name, _, wert = zeile.partition("=") + felder[name.strip()] = wert.strip() + return felder.get("LoadState", ""), felder.get("ActiveState", "") + + +def runner_urteil(laden: str, aktiv: str) -> str | None: + """laeuft, steht, oder None (fail-closed). steht nur bei einer geladenen Unit, die inactive oder failed ist.""" + if aktiv in RUNNER_LAEUFT: + return "laeuft" + if laden == "loaded" and aktiv in RUNNER_STEHT: + return "steht" + return None + + +def lies_alpaca() -> dict: + """Die JSON-Zeile von abbau_alpaca_lesen.py; ohne sie steht der Grund in beiden Fehlerfeldern.""" + try: + code, ausgabe, _ = wsl([ALPACA_PY, ALPACA_LESEN, "--projekt", ORDNER_WSL["alpaca"]], LESEN_TIMEOUT_S) + except Exception as ausnahme: + code, ausgabe = f"nicht gestartet, {type(ausnahme).__name__}", "" + for zeile in reversed(ausgabe.splitlines()): + zeile = zeile.strip() + if not zeile.startswith("{"): + continue + try: + daten = json.loads(zeile) + except ValueError: + break + if isinstance(daten, dict): + return daten + break + grund = f"abbau_alpaca_lesen.py ohne JSON-Zeile (Exit {code})" + return {"uhr_fehler": grund, "bestand_fehler": grund} + + +def uhr_lesbar(uhr) -> bool: + """Die Marktuhr in der Form von abbau_alpaca_lesen.py: offen als bool, Sekunden als ganze Zahl.""" + return ( + isinstance(uhr, dict) + and type(uhr.get("offen")) is bool + and type(uhr.get("sekunden_bis_schluss")) is int + ) + + +def alpaca_bestand(daten: dict) -> dict | None: + """Symbol -> Stueck und die Zahl offener Orders aus der JSON-Zeile, oder None, wenn nicht gelesen.""" + positionen, orders = daten.get("positionen"), daten.get("orders") + if not isinstance(positionen, list) or type(orders) is not int: + return None + stueck: dict[str, int] = {} + for eintrag in positionen: + if not isinstance(eintrag, dict) or not isinstance(eintrag.get("symbol"), str): + return None + if type(eintrag.get("menge")) is not int: + return None + stueck[eintrag["symbol"]] = stueck.get(eintrag["symbol"], 0) + eintrag["menge"] + return {"positionen": stueck, "orders": orders} + + +def ibkr_bestand(code: int, ausgabe: str) -> dict | None: + """Positionen und offene Orders aus stdout von scripts/ibkr_pruefen.py lesen, oder None. + + Gelesen ist der Bestand nur mit Exit 0 oder 1 (1: Befund zur Kontogroesse, die Liste steht + trotzdem da), mit der Zeile "ERGEBNIS: gelesen", beiden Zahlenzeilen und genau so vielen + Positionszeilen, wie "Positionen: n" sagt. + """ + zeilen = ausgabe.splitlines() + if not any(zeile.startswith(IBKR_GELESEN) for zeile in zeilen): + return None + if code not in (0, 1): + return None + anzahl = orders = None + gezaehlt = 0 + stueck: dict[str, int] = {} + im_block = False + for zeile in zeilen: + treffer = IBKR_POSITIONEN.match(zeile) + if treffer: + anzahl, im_block = int(treffer.group(1)), True + continue + treffer = IBKR_ORDERS.match(zeile) + if treffer: + orders, im_block = int(treffer.group(1)), False + continue + if im_block: + treffer = IBKR_ZEILE.match(zeile) + if treffer is None: + return None + symbol = treffer.group(1).split(".")[0] + stueck[symbol] = stueck.get(symbol, 0) + int(treffer.group(2)) + gezaehlt += 1 + if anzahl is None or orders is None or gezaehlt != anzahl: + return None + return {"positionen": stueck, "orders": orders} + + +def lies_ibkr() -> tuple[dict | None, int | None]: + """Bestand der IBKR-Instanz ueber abbau_ibkr_lesen.sh (ibkr_pruefen.py lesen) und dessen Exit.""" + try: + code, ausgabe, _ = wsl(["bash", IBKR_LESEN, ORDNER_WSL["ibkr"]], LESEN_TIMEOUT_S) + except Exception: + return None, None + return ibkr_bestand(code, ausgabe), code + + +def hat_bestand(bestand: dict | None) -> bool: + """Position ungleich null oder offene Order. Ein nicht gelesener Bestand (None) hat keinen: fail-closed.""" + return bestand is not None and (any(menge != 0 for menge in bestand["positionen"].values()) or bestand["orders"] > 0) + + +def ist_flach(bestand: dict | None) -> bool: + """Gelesen, keine Position ungleich null und keine offene Order.""" + return bestand is not None and not any(menge != 0 for menge in bestand["positionen"].values()) and bestand["orders"] == 0 + + +def flach_markiert(datei: Path) -> bool: + """Ob eine fruehere Ergebnisdatei "flach": true traegt; unlesbar oder anders heisst nein.""" + try: + daten = json.loads(datei.read_text(encoding="utf-8")) + except (OSError, ValueError): + return False + return isinstance(daten, dict) and daten.get("flach") is True + + +def beschreibe(bestand: dict | None) -> str: + """Ein Bestand fuer Ausgabe, Notiz und Meldung: Symbol, Richtung, Stueck, offene Orders. Nie ein Betrag.""" + if bestand is None: + return "nicht gelesen" + teile = [ + f"{symbol} {'long' if menge > 0 else 'short'} {abs(menge)}" + for symbol, menge in sorted(bestand["positionen"].items()) + if menge != 0 + ] + liste = ": " + ", ".join(teile) if teile else "" + return f"{len(teile)} Position(en){liste}; offene Orders {bestand['orders']}" + + +def glattstelle(instanz: str, modus: str) -> tuple[int, list[str]]: + """Genau ein Aufruf von abbau_glattstellen.sh fuer eine Instanz; nur im Modus scharf. + + Die Zeilen kommen aus WSL schon gefiltert (Wort, Kontonummern, Betraege); hier werden sie nur gekuerzt. + """ + if modus != SCHARF: + raise RuntimeError("glattstellen nur im Modus scharf") + code, ausgabe, fehler = wsl(["bash", GLATTSTELLEN, instanz, ORDNER_WSL[instanz]], GLATT_TIMEOUT_S) + zeilen = [zeile.strip()[:ZEILE_MAX] for zeile in (ausgabe + "\n" + fehler).splitlines() if zeile.strip()] + return code, zeilen[-ZEILEN_MAX:] + + +def melde(titel: str, text: str, modus: str) -> str: + """Eine Meldung mit hoher Prioritaet ueber abendkontrolle_wsl.py --meldung; nur im Modus scharf.""" + if modus != SCHARF: + return "nicht gesendet: Modus lesen" + titel, text = sauber(titel), sauber(text) + try: + code, ausgabe, _ = wsl([WSL_PY, WSL_SKRIPT, "--meldung", titel, text], MELDUNG_TIMEOUT_S) + except Exception as ausnahme: + return f"nicht gesendet: {type(ausnahme).__name__}" + for zeile in reversed(ausgabe.splitlines()): + zeile = zeile.strip() + if zeile.startswith("{"): + try: + return str(json.loads(zeile).get("meldung"))[:200] + except (ValueError, AttributeError): + break + return f"nicht gesendet: keine Antwort (Exit {code})" + + +def logge(ablage: Path, text: str) -> None: + """Eine Zeile in abbau.log; ein Fehler dabei haelt den Lauf nicht auf.""" + try: + ablage.mkdir(parents=True, exist_ok=True) + with (ablage / LOG_NAME).open("a", encoding="utf-8", newline="\n") as datei: + datei.write(f"{datetime.now().isoformat(timespec='seconds')} {text}\n") + except OSError: + pass + + +def schreibe_ergebnis(ablage: Path, daten: dict) -> None: + """abbau.json ueber eine Nachbardatei und os.replace: nie eine halbe Datei.""" + ablage.mkdir(parents=True, exist_ok=True) + neu = ablage / (ERGEBNIS_NAME + ".tmp") + neu.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8", newline="\n") + os.replace(neu, ablage / ERGEBNIS_NAME) + + +class Lauf: + """Was ein Lauf gesehen und getan hat; daraus entstehen Ausgabe, Ergebnisdatei, Notiz und Meldung.""" + + def __init__(self, jetzt: datetime, modus: str, ablage: Path) -> None: + self.jetzt = jetzt + self.modus = modus + self.ablage = ablage + self.ausgabe: list[str] = [] + self.befunde: list[str] = [] + self.gesperrt: set[str] = set() + self.markt = "" + self.vorher: dict = {} + self.nachher: dict = {} + self.eingriffe: dict = {} + self.spur = "" + + def sage(self, text: str) -> None: + self.ausgabe.append(einzeilig(text)) + + def befund(self, text: str) -> None: + self.befunde.append(einzeilig(text)[:300]) + + def zusammenfassung(self) -> list[str]: + """Je Instanz eine Zeile: vorher, was geschah, nachher.""" + zeilen = [] + for instanz in INSTANZEN: + eingriff = self.eingriffe.get(instanz) + tat = "nicht angefasst" if eingriff is None else f"glattstellen Exit {eingriff['exit']}" + zeilen.append( + f"{instanz}: vorher {beschreibe(self.vorher.get(instanz))}; {tat}; " + f"nachher {beschreibe(self.nachher.get(instanz))}" + ) + return zeilen + + +def fahre(lauf: Lauf) -> str: + """Schritte 1 bis 6; gibt NICHTS, STILL, LESEN_ENDE oder BERICHT zurueck.""" + scharf = lauf.modus == SCHARF + if not scharf: + lauf.sage("Modus lesen: kein Glattstellen, keine Meldung, keine Notiz, keine Datei") + # 1 Fenster und Ergebnisdatei + im_fenster = FRUEHESTENS <= lauf.jetzt <= SPAETESTENS + lage = "innerhalb" if im_fenster else "ausserhalb" + lauf.sage(f"Fenster {FRUEHESTENS:%Y-%m-%d %H:%M} bis {SPAETESTENS:%Y-%m-%d %H:%M}: {lage}") + if scharf and not im_fenster: + return NICHTS + if flach_markiert(lauf.ablage / ERGEBNIS_NAME): + lauf.sage("Ergebnisdatei sagt flach") + if scharf: + return STILL + + # 2 Runner-Units: ein laufender, nicht geladener oder unlesbarer Runner sperrt seine Instanz + for instanz in INSTANZEN: + try: + laden, aktiv = runner_felder(lies_runner(instanz)) + except Exception as ausnahme: + laden, aktiv = "", f"nicht lesbar, {type(ausnahme).__name__}" + zustand = f"LoadState={laden or 'leer'}, ActiveState={aktiv or 'leer'}" + lauf.sage(f"Runner {instanz} ({UNITS[instanz]}): {zustand}") + urteil = runner_urteil(laden, aktiv) + if urteil != "steht": + lauf.gesperrt.add(instanz) + grund = "laeuft" if urteil == "laeuft" else "Zustand nicht lesbar" + lauf.befund(f"{instanz}: Runner-Unit {UNITS[instanz]} {grund} ({zustand[:80]}); nicht angefasst") + + # 3 Marktuhr: zu oder bald zu heisst im Modus scharf still zu Ende + alpaca = lies_alpaca() + uhr = alpaca.get("uhr") + if not uhr_lesbar(uhr): + lauf.gesperrt.update(INSTANZEN) + lauf.markt = "Marktuhr nicht lesbar" + lauf.befund(f"Marktuhr nicht lesbar ({alpaca.get('uhr_fehler') or 'ohne Angabe'}); nichts angefasst") + else: + if uhr["offen"]: + lauf.sage(f"Markt offen, {uhr['sekunden_bis_schluss'] // 60} min bis Schluss") + else: + lauf.sage("Markt zu") + handelbar = uhr["offen"] and uhr["sekunden_bis_schluss"] >= MIN_BIS_SCHLUSS_S + if not handelbar: + lauf.markt = "Markt zu oder weniger als 10 min bis Schluss" + if scharf: + return STILL + + # 4 Bestaende: nicht lesbar heisst Befund; hat_bestand(None) ist falsch, die Instanz bleibt unberuehrt + lauf.vorher["alpaca"] = alpaca_bestand(alpaca) + if lauf.vorher["alpaca"] is None: + lauf.befund(f"alpaca: Bestand nicht lesbar ({alpaca.get('bestand_fehler') or 'ohne Angabe'}); nicht angefasst") + lauf.vorher["ibkr"], ibkr_exit = lies_ibkr() + if lauf.vorher["ibkr"] is None: + lauf.befund(f"ibkr: Bestand nicht lesbar (ibkr_pruefen.py lesen, Exit {ibkr_exit}); nicht angefasst") + for instanz in INSTANZEN: + lauf.sage(f"Bestand {instanz}: {beschreibe(lauf.vorher[instanz])}") + + # 5 Je Instanz hoechstens ein Eingriff, nur im Modus scharf; wirft er, gilt Exit None + zu_glatt = [instanz for instanz in INSTANZEN if instanz not in lauf.gesperrt and hat_bestand(lauf.vorher[instanz])] + if lauf.modus == LESEN: + entscheidung = f"keine ({lauf.markt})" if lauf.markt else (", ".join(zu_glatt) or "keine") + lauf.sage(f"Glattstellen im Modus scharf: {entscheidung}") + return LESEN_ENDE + for instanz in zu_glatt: + try: + ergebnis_code, zeilen = glattstelle(instanz, lauf.modus) + except Exception as ausnahme: + ergebnis_code, zeilen = None, [f"abgebrochen: {type(ausnahme).__name__}"] + lauf.eingriffe[instanz] = {"exit": ergebnis_code, "zeilen": zeilen} + if ergebnis_code != 0: + lauf.befund(f"{instanz}: glattstellen endete mit Exit {ergebnis_code}") + + # 6 Nach einem Eingriff warten und beide Bestaende neu lesen + if lauf.eingriffe: + schlafe(WARTEN_S) + lauf.nachher["alpaca"] = alpaca_bestand(lies_alpaca()) + lauf.nachher["ibkr"], _ = lies_ibkr() + for instanz in INSTANZEN: + if lauf.nachher[instanz] is None: + lauf.befund(f"{instanz}: Bestand nachher nicht lesbar; Ergebnis offen") + else: + lauf.nachher = dict(lauf.vorher) + return BERICHT + + +def ergebnis_daten(lauf: Lauf, flach: bool) -> dict: + """Inhalt von abbau.json: je Instanz vorher, nachher und das Glattstellen; dazu flach und die Befunde.""" + return { + "zeit": lauf.jetzt.isoformat(timespec="seconds"), + "flach": flach, + "befunde": lauf.befunde, + "instanzen": { + instanz: { + "vorher": lauf.vorher.get(instanz), + "nachher": lauf.nachher.get(instanz), + "glattstellen": lauf.eingriffe.get(instanz), + } + for instanz in INSTANZEN + }, + } + + +def titel_fuer(lauf: Lauf, flach: bool) -> str: + """Rangfolge: flach mit Befund, Befund, flach, Rest offen.""" + if flach and lauf.befunde: + return f"{TITEL}: flach, mit Befund" + if lauf.befunde: + return f"{TITEL}: Befund" + if flach: + return f"{TITEL}: flach" + return f"{TITEL}: Rest offen" + + +def notizzeile(lauf: Lauf, flach: bool, gemeldet: str) -> str: + """Die Zeile unter "## Agentenprotokoll", mit Unterpunkten. Nur Symbol, Richtung, Stueck, Zahlen.""" + if flach and lauf.befunde: + urteil = "flach, mit Befund #needs-review" + elif lauf.befunde: + urteil = "**Befund** #needs-review" + elif flach: + urteil = "flach" + else: + urteil = "Rest offen" + zeilen = [f"- {lauf.jetzt:%H:%M} Uhr - {KOPF}: {urteil}."] + zeilen += [f" - {teil}" for teil in lauf.zusammenfassung()] + for instanz, eingriff in lauf.eingriffe.items(): + if eingriff["exit"] != 0: + zeilen += [f" - {instanz}, killswitch.py: {text}" for text in eingriff["zeilen"][-5:]] + zeilen += [f" - Befund: {befund}" for befund in lauf.befunde] + if lauf.eingriffe: + zeilen.append(f" - {GESPERRT_HINWEIS}") + zeilen.append(f" - Meldung ans Telefon: {gemeldet}") + return "\n".join(zeilen) + + +def berichte(lauf: Lauf, vault: Path) -> int: + """Schritte 7 und 8: Ergebnisdatei und Log; Meldung und Notizzeile bei Eingriff, Befund oder flach.""" + # flach nur ohne gesperrte Instanz: Ein laufender oder unlesbarer Runner oder eine unlesbare + # Marktuhr kann den Bestand gleich wieder aendern; der naechste Termin beobachtet weiter. + flach = not lauf.gesperrt and all(ist_flach(lauf.nachher.get(instanz)) for instanz in INSTANZEN) + try: + schreibe_ergebnis(lauf.ablage, ergebnis_daten(lauf, flach)) + except OSError as ausnahme: + lauf.befund(f"Ergebnisdatei nicht geschrieben ({type(ausnahme).__name__})") + logge(lauf.ablage, f"scharf: {' | '.join(lauf.ausgabe + lauf.zusammenfassung() + lauf.befunde)} | flach={flach}") + if lauf.spur: + logge(lauf.ablage, lauf.spur) + code = 1 if lauf.befunde else 0 + # Still nur ohne Eingriff, ohne Befund und ohne flach. Das erste flach ist die Entwarnung; + # Laeufe nach der Marke enden schon in fahre(). + if not lauf.eingriffe and not lauf.befunde and not flach: + return code + titel = titel_fuer(lauf, flach) + text = "; ".join(lauf.zusammenfassung() + [f"Befund: {befund}" for befund in lauf.befunde]) + gemeldet = melde(titel, text[:1500].replace('"', "'"), lauf.modus) + try: + schreibe_notiz(vault, lauf.jetzt.date().isoformat(), notizzeile(lauf, flach, gemeldet)) + except Exception as ausnahme: + code = 1 + logge(lauf.ablage, f"Notiz nicht geschrieben: {type(ausnahme).__name__}") + return code + + +def main(argv=None) -> int: + zerleger = argparse.ArgumentParser(description=__doc__.splitlines()[0], allow_abbrev=False) + zerleger.add_argument( + "--modus", choices=MODI, required=True, + help="lesen: nur lesende Aufrufe, schreibt nichts; scharf: alles, nur ueber die Windows-Aufgabe", + ) + zerleger.add_argument("--jetzt", help="ISO-Zeitpunkt statt der Uhr (Angriffsfaelle)") + zerleger.add_argument("--projekt", default=str(PROJEKT), help="Projektordner unter Windows, fuer die Ablage") + zerleger.add_argument("--vault", default=str(VAULT)) + argumente = zerleger.parse_args(argv) + try: + jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now() + except ValueError: + zerleger.error("--jetzt ist kein ISO-Zeitpunkt") + lauf = Lauf(jetzt, argumente.modus, Path(argumente.projekt) / ABLAGE_TEIL) + try: + ende = fahre(lauf) + except Exception as ausnahme: + # Nur der Typ: Der Text einer fremden Ausnahme kann Kontonummer oder Betrag tragen. + # Der Traceback geht nur nach abbau.log (data/, nicht veroeffentlicht). + lauf.befund(f"abgebrochen: {type(ausnahme).__name__}") + lauf.spur = traceback.format_exc() + ende = BERICHT if lauf.modus == SCHARF else LESEN_ENDE + if ende == NICHTS: + return 0 + if ende == STILL: + logge(lauf.ablage, f"scharf, nichts angefasst: {' | '.join(lauf.ausgabe)}") + return 0 + if ende == LESEN_ENDE: + for zeile in lauf.ausgabe: + print(zeile) + for befund in lauf.befunde: + print(f"BEFUND: {befund}") + print(f"ERGEBNIS: gelesen, {'mit' if lauf.befunde else 'ohne'} Befund") + return 1 if lauf.befunde else 0 + return berichte(lauf, Path(argumente.vault)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/abbau_restbestand_angriffe.py b/.vault-tools/trading-plattform-pruefung/abbau_restbestand_angriffe.py new file mode 100644 index 0000000..004aa3a --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/abbau_restbestand_angriffe.py @@ -0,0 +1,817 @@ +"""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()) diff --git a/.vault-tools/trading-plattform-pruefung/abbau_restbestand_einrichten.ps1 b/.vault-tools/trading-plattform-pruefung/abbau_restbestand_einrichten.ps1 new file mode 100644 index 0000000..3ca70d8 --- /dev/null +++ b/.vault-tools/trading-plattform-pruefung/abbau_restbestand_einrichten.ps1 @@ -0,0 +1,54 @@ +# Richtet die einmalige Windows-Aufgabe "Trading Plattform - Abbau Restbestand" ein oder entfernt sie. +# Ersteller: Pascal Michel Erstellt: 2026-10-07 +# Doku: LIESMICH.md in diesem Ordner, Abschnitt "Paket AB" +# +# Pascal 2026-10-07: am 2026-10-08 soweit es geht alles verkaufen. Neun Ausloeser: acht Termine +# (Do 2026-10-08 15:45, 16:15, 17:00, 19:00, 21:00; Ersatz Fr 2026-10-09 15:45, 16:30, 19:00) und +# fuenf Minuten nach jeder Anmeldung. Der Anmelde-Ausloeser holt einen verpassten Termin nach, wie bei +# der Abendkontrolle; dass StartWhenAvailable das fuer eine Aufgabe mit Interactive allein tut, ist +# nicht belegt. abbau_restbestand.py tut ausserhalb seines Fensters und nach "flach" nichts; ein Lauf +# zur falschen Zeit ist also harmlos. Nach dem 2026-10-09 mit -Aktion entfernen abbauen, sonst bleibt +# der Anmelde-Ausloeser stehen. +# +# Aufruf: powershell -NoProfile -File abbau_restbestand_einrichten.ps1 [-Aktion einrichten|entfernen] +param( + [ValidateSet("einrichten", "entfernen")] + [string]$Aktion = "einrichten" +) + +$Name = "Trading Plattform - Abbau Restbestand" +if ($Aktion -eq "entfernen") { + Unregister-ScheduledTask -TaskName $Name -Confirm:$false + return +} + +$Py = "C:\Users\Pascal Michel\AppData\Local\Programs\Python\Python313\pythonw.exe" +$Skript = "D:\Claude\.vault-tools\trading-plattform-pruefung\abbau_restbestand.py" +if (-not (Test-Path $Py)) { throw "Interpreter fehlt: $Py" } +if (-not (Test-Path $Skript)) { throw "Skript fehlt: $Skript" } + +$Termine = @( + "2026-10-08T15:45:00", "2026-10-08T16:15:00", "2026-10-08T17:00:00", "2026-10-08T19:00:00", + "2026-10-08T21:00:00", "2026-10-09T15:45:00", "2026-10-09T16:30:00", "2026-10-09T19:00:00" +) +$Benutzer = "$env:USERDOMAIN\$env:USERNAME" +$Aufruf = New-ScheduledTaskAction -Execute $Py -Argument ('"' + $Skript + '" --modus scharf') -WorkingDirectory "D:\Claude" +$Anmeldung = New-ScheduledTaskTrigger -AtLogOn -User $Benutzer +$Anmeldung.Delay = "PT5M" +$Ausloeser = @($Termine | ForEach-Object { New-ScheduledTaskTrigger -Once -At ([datetime]$_) }) + @($Anmeldung) +$Wer = New-ScheduledTaskPrincipal -UserId $Benutzer -LogonType Interactive -RunLevel Limited +$Regeln = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit (New-TimeSpan -Minutes 45) -MultipleInstances IgnoreNew +Register-ScheduledTask -TaskName $Name -Action $Aufruf -Trigger $Ausloeser -Principal $Wer ` + -Settings $Regeln -Force ` + -Description "Abbau des Paper-Restbestands nach dem Netzschalter-Abriss vom 2026-10-07 (Auftrag Pascal). Siehe abbau_restbestand.py." | Out-Null + +$Aufgabe = Get-ScheduledTask -TaskName $Name +"{0}: {1}" -f $Aufgabe.TaskName, $Aufgabe.State +"Ausloeser: {0}" -f @($Aufgabe.Triggers).Count +$Aufgabe.Triggers | ForEach-Object { "Termin: {0} {1} {2}" -f $_.CimClass.CimClassName, $_.StartBoundary, $_.Delay } +"LogonType: {0}" -f $Aufgabe.Principal.LogonType +"StartWhenAvailable: {0}" -f $Aufgabe.Settings.StartWhenAvailable +"MultipleInstances: {0}" -f $Aufgabe.Settings.MultipleInstances +"ExecutionTimeLimit: {0}" -f $Aufgabe.Settings.ExecutionTimeLimit +"Naechster Lauf: {0}" -f (Get-ScheduledTaskInfo -TaskName $Name).NextRunTime diff --git a/AI/Regelwerk/windows-shell.md b/AI/Regelwerk/windows-shell.md index 4658586..7f08442 100644 --- a/AI/Regelwerk/windows-shell.md +++ b/AI/Regelwerk/windows-shell.md @@ -1,11 +1,11 @@ --- type: resource title: "Skripte und Shellbefehle unter Windows" -status: reference +status: pending-review summary: "Verbindliche Regeln für Befehle und Skripte auf Pascals Windows-Maschine: welche Shell, welches Python, Kodierung, Zeilenenden, Quoting-Schichten, WSL und ssh." tags: [regelwerk, windows, werkzeuge] created: 2026-09-13 -updated: 2026-09-13 +updated: 2026-10-07 --- # Skripte und Shellbefehle unter Windows @@ -44,6 +44,7 @@ Die Fallgeschichten stehen im Harness-Gedächtnis. Der Name der Erinnerung steht - **`wsl -d -- bash -lc '…'` expandiert jedes `$` schon auf der Windows-Seite**, auch in einfachen Anführungszeichen. Pfade schreibst du deshalb literal, Exit-Codes gibst du im WSL-Teil selbst aus, oder du legst das Skript als Datei ab. (`wsl-bash-lc-expandiert-windowsseitig`) - **Befehle an entfernte Linux-Hosts laufen als LF-Datei über stdin:** `cmd /c "ssh.exe root@ bash -s < datei.sh"`. Verschachtelte Anführungszeichen kommen aus PowerShell nicht heil an. (`ssh-aus-powershell-als-datei-ueber-stdin`) +- **`SSH_ASKPASS` ohne Leerzeichen im Pfad, und vor der ersten Verbindung einmal probeweise starten.** Windows-OpenSSH startet askpass per `posix_spawnp`; scheitert das an einem Leerzeichen, schickt ssh ein **leeres Passwort** und erzeugt einen Fehlversuch am Ziel, der eine Login-Sperre füttert. **`UserKnownHostsFile` ohne `~` im Pfad:** ssh expandiert die Tilde auch im Windows-Kurzpfad (`C:\Users\NAME~1\…`) und schreibt dann still keinen Hostschlüssel; Langpfad mit `/` in Anführungszeichen nehmen. (`ssh-aus-powershell-als-datei-ueber-stdin`) - **In einem über stdin gestarteten Skript liest kein Befehl stdin.** Das heißt `ssh -n`, `ausearch … < /dev/null`, nie `| sh`. Prüfskripte legst du erst nach `/tmp` und startest sie dann. (`pruefskript-liest-stdin`) ## Geheimnisse in Ausgaben