Automatischer Push: 8 Datei(en) geändert

- .vault-tools/trading-plattform-pruefung/LIESMICH.md
- .vault-tools/trading-plattform-pruefung/abbau_alpaca_lesen.py
- .vault-tools/trading-plattform-pruefung/abbau_glattstellen.sh
- .vault-tools/trading-plattform-pruefung/abbau_ibkr_lesen.sh
- .vault-tools/trading-plattform-pruefung/abbau_restbestand.py
- .vault-tools/trading-plattform-pruefung/abbau_restbestand_angriffe.py
- .vault-tools/trading-plattform-pruefung/abbau_restbestand_einrichten.ps1
- AI/Regelwerk/windows-shell.md

Erzeugt vom PostToolUse-Hook .vault-tools/auto-push/auto_push.py.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Pascal MichelandClaude Opus 5 committed 2026-10-08 04:00:01 +02:00
1 parent 30ed04a643
commit 3784322a76
8 files changed
+1687 -3

No files matched your search

@@ -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 <instanz> <instanzordner> [--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 <alpaca-ordner>` |
| `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.
@@ -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 <alpaca-ordner>
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())
@@ -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 <alpaca|ibkr> <instanzordner> [--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 <alpaca|ibkr> <instanzordner> [--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"
@@ -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 <instanzordner>
# 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 <instanzordner>" >&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
@@ -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/<tag>.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())
@@ -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_<was er angreift>; 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())
@@ -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
+3 -2
View File
@@ -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 <Distro> -- 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 <optionen> root@<host> 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