Compare commits

..
9 Commits
Author SHA1 Message Date
Pascal MichelandClaude Opus 5 f73f84e3ac Automatischer Push: .claude/agents/planer.md
- .claude/agents/planer.md

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-12 04:00:01 +02:00
Pascal MichelandClaude Opus 5.5 8b53bf7a74 Trading-Plattform-Pruefung: LIESMICH mit Abschnitt Paket NT (Trading Plattform 0.39.1, Task 731)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0126RDfXiECscXipzf67uypJ
2026-10-12 01:03:44 +02:00
Pascal MichelandClaude Opus 5.5 ef8059903c Trading-Plattform-Pruefung: Bestaetigung ohne Host bricht die Sicherung ab, als Fall belegt (Paket NT, Task 739)
- abendkontrolle_dockerhost_angriffe.py: Fall eine_bestaetigung_ohne_host_bricht_die_sicherung_ab
  und Mutant E739-01 (39 Faelle, 57 Mutanten, keiner ueberlebt).
- abendkontrolle.py: Docstring von ssh_lauf nennt den Vorbehalt zu Win32-OpenSSH.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0126RDfXiECscXipzf67uypJ
2026-10-12 00:09:38 +02:00
Pascal MichelandClaude Opus 5.5 88ec4fd40f Trading-Plattform-Pruefung: Host nicht erreichbar gibt eine Zeile, --tag frueh geprueft (Paket NT, Task 729)
- abendkontrolle.py: Exit 255 oder Zeitlimit bei Summe oder Bestaetigung beendet die Sicherung mit
  genau einer Zeile in der Notiz von heute und Exit 1; ein Abbruch im Archiv laesst nur die Instanz
  scheitern. --tag ausser der Form endet gleich mit Exit 2, ohne Notiz und ssh.
- abendkontrolle_dockerhost_angriffe.py: sieben neue Faelle, zwei angepasste, Mutanten E729-01 bis
  E729-15 und E729-25 (38 Faelle, 56 Mutanten, keiner ueberlebt).
- abendkontrolle_wsl.py: Kopf nennt die neuen fehler-Gruende.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0126RDfXiECscXipzf67uypJ
2026-10-11 23:25:38 +02:00
Pascal MichelandClaude Opus 5.5 02ff0e878d Trading-Plattform-Pruefung: LIESMICH nennt den Fall Host nicht erreichbar (Quorum 4, G1)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0126RDfXiECscXipzf67uypJ
2026-10-11 20:04:38 +02:00
Pascal MichelandClaude Opus 5.5 639c76bddc Trading-Plattform-Pruefung: LIESMICH mit Abschnitt Paket E9 (Trading Plattform 0.39.0, Task 727)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0126RDfXiECscXipzf67uypJ
2026-10-11 19:41:48 +02:00
Pascal MichelandClaude Opus 5.5 3e55bb915d Trading-Plattform-Pruefung: Abendkontrolle prueft die Sicherung des vorigen Handelstags (Paket E9, Task 726)
abendkontrolle_wsl.py nimmt --sicherung (Stand der Ablage vom Host) und meldet per ntfy, wenn die
Sicherung des vorigen Handelstags fehlt, kein Archiv hat, nicht bestaetigt ist oder die Bestaetigung
nicht passt; vor erster-tag still, ohne erster-tag ein Befund. abendkontrolle_angriffe.py mit
--nur-sicherung (60 Faelle), abendkontrolle_mutanten.py mit --nur und 30 Sicherungs-Mutanten.
Gegengeprueft: byte-gleich mit der Plankopie, Lauf gegen echte Archive ohne Abweichung.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0126RDfXiECscXipzf67uypJ
2026-10-11 17:09:14 +02:00
Pascal MichelandClaude Opus 5.5 c5db92c6de Trading-Plattform-Pruefung: Abendkontrolle holt die Sicherung des Docker-Hosts (Paket E9, Task 725)
abendkontrolle.py holt je Teil mit Quelle dockerhost die Sicherungen der letzten zehn Kalendertage,
prueft die Summe gegen die des Hosts, legt unter D:\Trading-Sicherung ab, bestaetigt beim Host und
schreibt zuletzt die Summendatei. Zeitbudget 1500 s ab dem Start des Laufs. abendkontrolle_einrichten.ps1
setzt den Abend-Ausloeser in Ortszeit ohne UTC-Versatz (S10). Treiber: 32 Faelle, 42 Mutanten.
Gegengeprueft: byte-gleich mit der Plankopie, Kette mit echten Archiven ueber das echte
abendkontrolle-lesen.sh gruen. SICHERUNG_ERSTER_TAG bleibt None bis zum Einspielen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0126RDfXiECscXipzf67uypJ
2026-10-11 16:58:56 +02:00
Pascal MichelandClaude Opus 5.5 77de5059e4 plan_materialisiere: nur innerhalb der Kopie schreiben; absoluter Pfad oder .. ist ein Befund
Ein absoluter Pfad in einer Ueberschrift schlug unter Windows die Kopie (kopie / "D:/..."),
und das Skript ersetzte die echte Datei mit Exit 0. Gemessen an einer Probe-Datei im Scratchpad,
entdeckt beim Planen von Paket E9 der Trading Plattform (Tasks 725 und 726 aendern Vault-Werkzeuge).
Neu: in_kopie() an allen drei Schreibstellen. Regressionsnachweis: drei Ausbruchsfaelle je Befund
und Exit 2, Dateien draussen unveraendert; Gegenprobe mit E9 723 und 724 byte-gleich.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0126RDfXiECscXipzf67uypJ
2026-10-11 13:49:01 +02:00
10 changed files with 2141 additions and 14 deletions

No files matched your search

+1
View File
@@ -23,6 +23,7 @@ Du denkst, du tippst nicht. Dein Ergebnis ist ein Plan, den ein Executer ohne R
- `rot_bei` nennt parametrisierte Tests mit voller Kennung, und zwar nur die Fälle, die der Mutant umkippt. Bei B1 (2026-09-27) wären unter `return False` drei von fünf Fällen grün geblieben. - `rot_bei` nennt parametrisierte Tests mit voller Kennung, und zwar nur die Fälle, die der Mutant umkippt. Bei B1 (2026-09-27) wären unter `return False` drei von fünf Fällen grün geblieben.
- `aus` und `wird` stehen in `'''…'''`, damit Backslashes wörtlich bleiben. - `aus` und `wird` stehen in `'''…'''`, damit Backslashes wörtlich bleiben.
- **Keine Platzhalter.** Kein „analog zu Task 2", kein „hier noch ergänzen", kein Pseudocode. Was im Plan steht, ist tippfertig. - **Keine Platzhalter.** Kein „analog zu Task 2", kein „hier noch ergänzen", kein Pseudocode. Was im Plan steht, ist tippfertig.
- **Blöcke in der Form, die `plan_materialisiere.py` anwendet.** Die Marke steht als letzte nicht leere Zeile vor dem Zaun, ohne Formatierung: `Aus:`, `wird:`, `Die ganze Datei ist neu:` oder `Häng diesen Block ans Ende:`. Die Überschrift (`##` bis `####`) über dem Block endet mit dem Pfad der Zieldatei in Backticks; ein Pfad ohne Schrägstrich und ohne Endung `.py`, `.md`, `.toml`, `.json`, `.ts` oder `.css` steht als `./name` (etwa `./versions.env`). Eine fette Marke oder eine Überschrift, deren letzter Pfad nicht das Ziel ist, übergeht das Werkzeug ohne Fehler. Am 2026-10-11 im Edge-Stack betraf das 8 von 11 Taskdateien, die Plankritik hätte ohne Kopie gearbeitet.
- **Nachbesserung nach einer ausgeführten Welle schreibst du als neue Taskdateien, nicht als Neufassung.** Du hast kein Edit-Werkzeug und schreibst jede überarbeitete Datei komplett neu; am 2026-09-23 im Nexhorizons Hub waren dabei 10 von 17 Befunden der Nachprüfung durch das Umschreiben selbst entstanden. Deshalb: neue `task-NN-nachbesserung-<thema>.md` mit `Aus:`/`wird:`-Blöcken gegen den Baum, bestehende Taskdateien bleiben byte-gleich. Was ältere Tasks an den neuen Stand anpassen muss, schreibst du als Folgenliste `nachbesserung-<welle>-folgen.md` im selben `Aus:`/`wird:`-Format; eingesetzt wird sie vom Koordinator oder einem Executer. Für Korrekturen vor der ersten Ausführung gilt das nicht, außer der Auftrag verlangt es. - **Nachbesserung nach einer ausgeführten Welle schreibst du als neue Taskdateien, nicht als Neufassung.** Du hast kein Edit-Werkzeug und schreibst jede überarbeitete Datei komplett neu; am 2026-09-23 im Nexhorizons Hub waren dabei 10 von 17 Befunden der Nachprüfung durch das Umschreiben selbst entstanden. Deshalb: neue `task-NN-nachbesserung-<thema>.md` mit `Aus:`/`wird:`-Blöcken gegen den Baum, bestehende Taskdateien bleiben byte-gleich. Was ältere Tasks an den neuen Stand anpassen muss, schreibst du als Folgenliste `nachbesserung-<welle>-folgen.md` im selben `Aus:`/`wird:`-Format; eingesetzt wird sie vom Koordinator oder einem Executer. Für Korrekturen vor der ersten Ausführung gilt das nicht, außer der Auftrag verlangt es.
- **Offene Entscheidungen benennen, nicht überspielen.** Wo der Auftrag mehrdeutig ist: Annahme treffen, im Abschnitt „Getroffene Annahmen" explizit nennen. Nur abbrechen, wenn jede Annahme den Plan unbrauchbar machen würde. - **Offene Entscheidungen benennen, nicht überspielen.** Wo der Auftrag mehrdeutig ist: Annahme treffen, im Abschnitt „Getroffene Annahmen" explizit nennen. Nur abbrechen, wenn jede Annahme den Plan unbrauchbar machen würde.
+2
View File
@@ -58,6 +58,8 @@ Nachbesserung vom 2026-10-09 (Paket RB3 der Trading Plattform, Plan `docs/plans/
- Lauf mit echten Daten: die 73 Mutanten des Pakets RB3 (Kopie `rb3d-kopie`) durch das neue Skript, alle `OK`, T1 und T12 im Namensraum. - Lauf mit echten Daten: die 73 Mutanten des Pakets RB3 (Kopie `rb3d-kopie`) durch das neue Skript, alle `OK`, T1 und T12 im Namensraum.
- Ein `pruefer` fand keinen blockierenden Befund. Seine Lücken (kein Fall für den Fehlerzweig der Probe und den Zweig nach `||` im Hauptaufruf, Kopfkommentar zu weit gefasst, der Satz zur Reihenfolge oben) behebt Nachbesserung 1. Rohdaten: `AI/secondbrain/trading-plattform-memory/2026-10-09-mkopie-roh/`. - Ein `pruefer` fand keinen blockierenden Befund. Seine Lücken (kein Fall für den Fehlerzweig der Probe und den Zweig nach `||` im Hauptaufruf, Kopfkommentar zu weit gefasst, der Satz zur Reihenfolge oben) behebt Nachbesserung 1. Rohdaten: `AI/secondbrain/trading-plattform-memory/2026-10-09-mkopie-roh/`.
Seit 2026-10-11 schreibt `plan_materialisiere.py` nur noch innerhalb der Kopie. Vorher schlug ein absoluter Pfad in einer Überschrift unter Windows die Kopie (`kopie / "D:/…"` ist der absolute Pfad): Das Skript ersetzte dann die echte Datei, mit Exit 0 und „0 Befund(e)“. Gemessen an einer Probe-Datei im Scratchpad. Entdeckt beim Planen von Paket E9 der Trading Plattform, dessen Tasks 725 und 726 Dateien unter `D:/Claude/.vault-tools/` ändern. Jetzt ist ein Ziel außerhalb der Kopie (absolut oder mit `..`) ein Befund mit Exit 2, und nichts wird geschrieben. Belegt am selben Tag mit drei Fällen (Aus/wird absolut, ganze Datei absolut, `../`): je Befund, die Dateien draußen unverändert; und mit einer Gegenprobe: Die Tasks 723 und 724 von E9 ergeben in einer frischen Kopie dieselben Zählungen und byte-gleiche Dateien. Pläne für Dateien außerhalb des Projekts nennen sie deshalb relativ zu einem Wurzelordner (etwa `.vault-tools/trading-plattform-pruefung/abendkontrolle.py` gegen eine Kopie, die diesen Ordner enthält).
Seit 2026-09-18 nimmt `welle_vergleich.py` unter `## Zieldateien` auch Pfade relativ zum Projekt (ohne Laufwerk und ohne Wurzel), weil Pläne für einen Git-Worktree keine absoluten Pfade nennen; ein `..` darin ist ein Befund wie ein fremder absoluter Pfad. Jede Zeile braucht weiter die Klammer hinter dem Pfad (`(neu)`, `(geändert)`, `(gelöscht)`). `plan_materialisiere.py` erkennt eine Zieldatei ohne Schrägstrich nur mit den Endungen py, ts, tsx, toml, md, css und json — für `compose.yaml` in der Projektwurzel schreibt die Überschrift `./compose.yaml`. Der Treiber fährt dazu drei weitere Fälle (relative Grundlinie, relative Abweichung, `..` nach außen), zusammen 17. Seit 2026-09-18 nimmt `welle_vergleich.py` unter `## Zieldateien` auch Pfade relativ zum Projekt (ohne Laufwerk und ohne Wurzel), weil Pläne für einen Git-Worktree keine absoluten Pfade nennen; ein `..` darin ist ein Befund wie ein fremder absoluter Pfad. Jede Zeile braucht weiter die Klammer hinter dem Pfad (`(neu)`, `(geändert)`, `(gelöscht)`). `plan_materialisiere.py` erkennt eine Zieldatei ohne Schrägstrich nur mit den Endungen py, ts, tsx, toml, md, css und json — für `compose.yaml` in der Projektwurzel schreibt die Überschrift `./compose.yaml`. Der Treiber fährt dazu drei weitere Fälle (relative Grundlinie, relative Abweichung, `..` nach außen), zusammen 17.
Seit 2026-09-24 hat `rot_nachweis.py` die Schalter `--venv`, `--frontend` und `--projektname`; vorher waren `~/venvs/trading`, `dashboard` und `trading-plattform/` fest verdrahtet. Ein Hub-Lauf im Trading-Venv wäre für die Tasks mit `argon2-cffi` und `pyotp` rot aus einem fehlenden Paket gewesen, nicht aus der fehlenden Umsetzung. Fehlt das Venv in WSL, steht beim Task jetzt `VORAUSSETZUNG FEHLT` statt `ROT`, und die Schlusszeile zählt diese Läufe getrennt. Belegt am selben Tag: Der Vorgabe-Befehl ist byte-gleich mit der alten Fassung, falsche Schalterwerte enden mit Exit 3, und der erste echte Lauf über 38 Tasks des Hub meldete keinen grün ohne Umsetzung. Im selben Lauf zeigte sich eine zweite Lücke: Beim Zurücksetzen blieben Ordner stehen, die erst der Task angelegt hatte. Ein leeres `hub/auth/` liest Python als Namespace-Paket, und die Fehlerzeile nannte `hub.auth.passwort` statt `hub.auth`. Jetzt gehen solche Ordner mit, sobald sie leer sind; derselbe Lauf für die Tasks 7, 9 und 11 zeigt danach genau die Zeile aus dem Plan. Die unabhängige Prüfung desselben Tages fand eine dritte Lücke: Ein vergessenes `--venv` fiel still auf das Trading-Venv zurück, und Task 7 stand als `ROT … No module named 'argon2'` da. Seitdem wird eine erste Fehlerzeile, die ein Modul außerhalb des Projekts und außerhalb der Zieldateien vermisst, als `VORAUSSETZUNG FEHLT` gemeldet. Das trifft auch den Ende-zu-Ende-Task des Hub, dessen Tests `playwright` brauchen. Mit den Vorgaben lieferte das Skript für die sieben Tasks des Trading-Pakets K vor und nach allen Änderungen dieselben Zeilen. Zwei weitere Lücken desselben Tages, beide ein ROT, das nichts misst: **Ein Arbeitsordner mit Windows-Kurznamen** (`C:/Users/PASCAL~1/…`) ließ vitest dort starten, die Dateien aber über den langen Namen auflösen, und jede Oberflächen-Testdatei endete als `Cannot find module '/src/…'`. Das Skript löst den Arbeitsordner jetzt auf den langen Namen auf; danach zeigte Trading-Task 395 wieder `9 failed | 52 passed` statt `no tests`, und die Hub-Tasks 24, 25, 29 und 37 die Zeile aus dem Plan (`Error: Failed to resolve import "./kontrast" …`). Die erste `Error:`-Zeile steht seitdem mit in der Ergebniszeile, weil die `FAIL`-Zeile allein den Grund nicht zeigt. **Ein Test, der über Vite eine Datei aus `node_modules` lädt** (`?raw`), scheitert hinter der Junction mit `Denied ID`, weil Vite nichts außerhalb seines Wurzelordners ausliefert; das Skript meldet das als `VORAUSSETZUNG FEHLT`. Für solche Tasks läuft der Nachweis in einer Kopie mit echtem `node_modules` (242 MB, gut 20 Sekunden). Seit 2026-09-24 hat `rot_nachweis.py` die Schalter `--venv`, `--frontend` und `--projektname`; vorher waren `~/venvs/trading`, `dashboard` und `trading-plattform/` fest verdrahtet. Ein Hub-Lauf im Trading-Venv wäre für die Tasks mit `argon2-cffi` und `pyotp` rot aus einem fehlenden Paket gewesen, nicht aus der fehlenden Umsetzung. Fehlt das Venv in WSL, steht beim Task jetzt `VORAUSSETZUNG FEHLT` statt `ROT`, und die Schlusszeile zählt diese Läufe getrennt. Belegt am selben Tag: Der Vorgabe-Befehl ist byte-gleich mit der alten Fassung, falsche Schalterwerte enden mit Exit 3, und der erste echte Lauf über 38 Tasks des Hub meldete keinen grün ohne Umsetzung. Im selben Lauf zeigte sich eine zweite Lücke: Beim Zurücksetzen blieben Ordner stehen, die erst der Task angelegt hatte. Ein leeres `hub/auth/` liest Python als Namespace-Paket, und die Fehlerzeile nannte `hub.auth.passwort` statt `hub.auth`. Jetzt gehen solche Ordner mit, sobald sie leer sind; derselbe Lauf für die Tasks 7, 9 und 11 zeigt danach genau die Zeile aus dem Plan. Die unabhängige Prüfung desselben Tages fand eine dritte Lücke: Ein vergessenes `--venv` fiel still auf das Trading-Venv zurück, und Task 7 stand als `ROT … No module named 'argon2'` da. Seitdem wird eine erste Fehlerzeile, die ein Modul außerhalb des Projekts und außerhalb der Zieldateien vermisst, als `VORAUSSETZUNG FEHLT` gemeldet. Das trifft auch den Ende-zu-Ende-Task des Hub, dessen Tests `playwright` brauchen. Mit den Vorgaben lieferte das Skript für die sieben Tasks des Trading-Pakets K vor und nach allen Änderungen dieselben Zeilen. Zwei weitere Lücken desselben Tages, beide ein ROT, das nichts misst: **Ein Arbeitsordner mit Windows-Kurznamen** (`C:/Users/PASCAL~1/…`) ließ vitest dort starten, die Dateien aber über den langen Namen auflösen, und jede Oberflächen-Testdatei endete als `Cannot find module '/src/…'`. Das Skript löst den Arbeitsordner jetzt auf den langen Namen auf; danach zeigte Trading-Task 395 wieder `9 failed | 52 passed` statt `no tests`, und die Hub-Tasks 24, 25, 29 und 37 die Zeile aus dem Plan (`Error: Failed to resolve import "./kontrast" …`). Die erste `Error:`-Zeile steht seitdem mit in der Ergebniszeile, weil die `FAIL`-Zeile allein den Grund nicht zeigt. **Ein Test, der über Vite eine Datei aus `node_modules` lädt** (`?raw`), scheitert hinter der Junction mit `Denied ID`, weil Vite nichts außerhalb seines Wurzelordners ausliefert; das Skript meldet das als `VORAUSSETZUNG FEHLT`. Für solche Tasks läuft der Nachweis in einer Kopie mit echtem `node_modules` (242 MB, gut 20 Sekunden).
+33 -3
View File
@@ -62,6 +62,23 @@ def bloecke(taskdatei: Path):
i += 1 i += 1
def in_kopie(kopie: Path, ziel: str) -> Path | None:
"""Der Zielpfad in der Kopie, oder None, wenn er aus ihr hinausfuehrt.
Nachbesserung 2026-10-11 (Paket E9 der Trading Plattform): Ein absoluter Pfad in einer
Ueberschrift schlug unter Windows die Kopie (kopie / "D:/..." ist der absolute Pfad), und das
Skript schrieb mit Exit 0 in die echte Datei; gemessen im Scratchpad an einer Probe-Datei.
Seitdem ist ein Ziel ausserhalb der Kopie (absolut oder mit "..") ein Befund, und nichts wird
geschrieben.
"""
pfad = (kopie / ziel).resolve()
try:
pfad.relative_to(kopie.resolve())
except ValueError:
return None
return pfad
LOESCHZIEL = re.compile(r"^- `(.+?)`\s*\((.*)\)\s*$") LOESCHZIEL = re.compile(r"^- `(.+?)`\s*\((.*)\)\s*$")
@@ -110,7 +127,12 @@ def main() -> int:
print(f"BEFUND {name}:{block['zeile']}: wird-Block ohne Aus-Block oder ohne Zieldatei") print(f"BEFUND {name}:{block['zeile']}: wird-Block ohne Aus-Block oder ohne Zieldatei")
befunde += 1 befunde += 1
continue continue
pfad = kopie / ziel pfad = in_kopie(kopie, ziel)
if pfad is None:
print(f"BEFUND {name}:{block['zeile']}: Zieldatei {ziel} liegt ausserhalb der Kopie, nichts geschrieben")
befunde += 1
alt = None
continue
if not pfad.exists(): if not pfad.exists():
print(f"BEFUND {name}:{block['zeile']}: Zieldatei {ziel} fehlt in der Kopie") print(f"BEFUND {name}:{block['zeile']}: Zieldatei {ziel} fehlt in der Kopie")
befunde += 1 befunde += 1
@@ -129,14 +151,22 @@ def main() -> int:
continue continue
alt = None alt = None
if ("Die ganze Datei ist neu" in marke or "vollständig ersetzt" in marke) and ziel is not None: if ("Die ganze Datei ist neu" in marke or "vollständig ersetzt" in marke) and ziel is not None:
pfad = kopie / ziel pfad = in_kopie(kopie, ziel)
if pfad is None:
print(f"BEFUND {name}:{block['zeile']}: Zieldatei {ziel} liegt ausserhalb der Kopie, nichts geschrieben")
befunde += 1
continue
pfad.parent.mkdir(parents=True, exist_ok=True) pfad.parent.mkdir(parents=True, exist_ok=True)
with open(pfad, "w", encoding="utf-8", newline="\n") as strom: with open(pfad, "w", encoding="utf-8", newline="\n") as strom:
strom.write(block["text"].rstrip("\n") + "\n") strom.write(block["text"].rstrip("\n") + "\n")
zaehler["neu"] += 1 zaehler["neu"] += 1
continue continue
if marke.startswith("Häng diesen Block ans Ende") and ziel is not None: if marke.startswith("Häng diesen Block ans Ende") and ziel is not None:
pfad = kopie / ziel pfad = in_kopie(kopie, ziel)
if pfad is None:
print(f"BEFUND {name}:{block['zeile']}: Zieldatei {ziel} liegt ausserhalb der Kopie, nichts geschrieben")
befunde += 1
continue
text = normal(pfad.read_text(encoding="utf-8")) text = normal(pfad.read_text(encoding="utf-8"))
with open(pfad, "w", encoding="utf-8", newline="\n") as strom: with open(pfad, "w", encoding="utf-8", newline="\n") as strom:
strom.write(text.rstrip("\n") + "\n" + block["text"].rstrip("\n") + "\n") strom.write(text.rstrip("\n") + "\n" + block["text"].rstrip("\n") + "\n")
@@ -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." 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] tags: [vault-tools, trading-plattform]
created: 2026-09-17 created: 2026-09-17
updated: 2026-10-08 updated: 2026-10-12
--- ---
# Prüfwerkzeuge fürs Dashboard der Trading Plattform # Prüfwerkzeuge fürs Dashboard der Trading Plattform
@@ -180,6 +180,62 @@ Nach dem Umzug auf den Docker-Host (Hyper-V-VM) prüft der Host selbst: `deploy/
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). 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 E9 (Trading Plattform 0.39.0): Sicherung nach D:
Seit Paket E9 sichert der Docker-Host nach jedem Werktag um 16:45 New Yorker Zeit Register, `data/paper` und `artifacts/paper` beider Instanzen in seine Ablage (`deploy/docker-host/sicherung-host.sh` aus dem Repo). Die Werkzeuge hier holen die Sicherung nach `D:\Trading-Sicherung`, bestätigen sie beim Host und melden über die Abendkontrolle des Hosts, wenn sie fehlt oder unbestätigt ist. Plan im Projekt: `docs/plans/2026-10-11-paket-e9-sicherung/`; Codedoku dort in `docs/code/deployment.md`, Unterabschnitt „Sicherung (seit 0.39.0, Paket E9)“.
- **Gebaut und geprüft, nicht eingespielt.** `SICHERUNG_ERSTER_TAG` in `abendkontrolle.py` steht auf `None`, bis der Koordinator E9 mit Pascals Ja einspielt; bis dahin holt `abendkontrolle.py` nichts ab.
- Beim Einspielen kommt `abendkontrolle_wsl.py` vor `host-einrichten.sh --anwenden` nach `/opt/trading-plattform/werkzeuge/`. Umgekehrt lehnt das alte Werkzeug `--sicherung` ab, und jede Abendkontrolle endet ohne Ergebnis.
- Die Zahlen der Fälle in den Abschnitten oben gelten für den Stand vor E9; wie viele es jetzt sind, sagt die Zählzeile des Laufs.
- Das Fenster von `abendkontrolle.py` (heute und zehn Kalendertage davor) ist kürzer als die Aufbewahrung des Hosts (zehn Sicherungstage). War der Rechner länger aus, fallen ältere Tage still heraus; die holt nur ein Aufruf mit `--tag`.
- Bis 0.39.0 scheiterte bei einem Lauf ohne erreichbaren Host jeder Tag des Fensters mit einer eigenen Zeile `#needs-review`, auch ein Tag, der schon vollständig auf D: lag (Quorum 4 zur Trading Plattform, Befund G1). Seit 0.39.1 steht dann genau eine Zeile in der Notiz von heute (Abschnitt „Paket NT“ unten).
| Datei | Was sich ändert | Aufruf |
|---|---|---|
| `abendkontrolle.py` | holt für jeden Teil mit Quelle `dockerhost` nach den Ergebnissen die Sicherungen ab. Je Kalendertag der letzten `SICHERUNG_NACHHOLEN_TAGE` (10) bis heute ab `SICHERUNG_ERSTER_TAG` fragt es die Summe des Hosts (`sicherung-summe`); wo `<instanz>.tar.sha256` auf D: fehlt oder eine andere Summe trägt, holt es das Archiv in `<instanz>.tar.teil` (`sicherung`, 600 s), prüft die Summe, benennt nach `<tag>\<instanz>.tar` um, bestätigt beim Host (`sicherung-bestaetigt`) und schreibt zuletzt die Summendatei. Exit 3 des Hosts ist für heute und das Wochenende still, für einen vergangenen Werktag ein Fehlschlag. Zeitbudget 1500 s ab dem Start des Laufs. Je Tag mit Neuem eine Zeile „Sicherung Trading Plattform (automatisch)“ in der Notiz dieses Tags, ein Fehlschlag mit `#needs-review`. Mit `--tag` nur dieser Tag, auch einer vor `SICHERUNG_ERSTER_TAG`. `--sicherung-ziel` lenkt die Ablage um, nur für die Angriffsfälle | `python abendkontrolle.py --quelle dockerhost --quelle-ibkr dockerhost [--tag JJJJ-MM-TT]` |
| `abendkontrolle_einrichten.ps1` | setzt den `StartBoundary` des Abend-Auslösers ohne UTC-Versatz (`JJJJ-MM-TTT23:15:00`), damit die Aufgabe nach Ortszeit startet (S10); wirkt erst, wenn die Aufgabe neu eingetragen ist | `powershell -NoProfile -File abendkontrolle_einrichten.ps1 -Quelle dockerhost -QuelleIbkr dockerhost` |
| `abendkontrolle_wsl.py` | `--sicherung <datei>`: prüft für den Teil die Sicherung des vorigen Handelstags nach dem Kalender (zehn Kalendertage vor `--tag` bis zum Vortag; die Spanne setzt das Werkzeug selbst durch, auch für eine Kalenderdatei) gegen den Stand, den `abendkontrolle-host.sh` als JSON schreibt: Eintrag, Summe, Archiv, Bestätigung gleich der Summe, dazu ein fehlender `erster-tag` und einer nach dem geprüften Tag. Jede Abweichung ist ein Befund und geht per ntfy hinaus; still bleibt nur ein voriger Handelstag vor dem ersten Sicherungstag. Ohne den Schalter wie vorher | auf dem Host über `abendkontrolle-host.sh`; von Hand in WSL `python abendkontrolle_wsl.py --tag JJJJ-MM-TT --sicherung <datei> --ohne-meldung` (ohne `--ohne-meldung` geht jeder Befund per ntfy hinaus) |
| `abendkontrolle_angriffe.py` | dazu die Fälle zur Sicherung in WSL: Stand unlesbar, `fehler`, `erster_tag` fehlt, kaputt oder nach dem geprüften Tag, jede Abweichung bei Eintrag, Summe, Archiv und Bestätigung, Grenzen des Kalenderfensters mit Kalenderdatei, Kalender in falscher Form, Bedienfehler und ein Meldefall gegen `ntfy_probe_server.py` | in WSL im Venv `python abendkontrolle_angriffe.py --nur-sicherung` (nur diese Fälle) oder ohne Schalter alle |
| `abendkontrolle_mutanten.py` | `--nur TEXT` fährt nur die Mutanten mit TEXT im Namen; ein fünftes Element im Tupel geht als Argument an den Treiber. Neu sind die Mutanten „Sicherung …“ von `abendkontrolle_wsl.py`; sie fahren den Treiber mit `--nur-sicherung`. Ohne `--nur` fährt jeder bisherige Mutant den ganzen Treiber samt Sicherungsfällen und dauert länger | in WSL im Venv `timeout 3600 python abendkontrolle_mutanten.py --nur Sicherung`; Exit 0, wenn keiner überlebt |
| `abendkontrolle_dockerhost_angriffe.py` | Die `ssh.exe`-Attrappe kennt die drei Sicherungsbefehle so streng wie `abendkontrolle-lesen.sh` (Formen, Exit 0 bis 4), gegen eine Host-Ablage im Wegwerf-Ordner. Neue Fälle zu Holen, Prüfen, Ablegen und Bestätigen, falscher Summe, abgebrochener Übertragung, Exit 3, schon vorhandener Datei, fremdem Inhalt im Zielordner, gescheiterter Bestätigung, Fenster, Zeitbudget und dem Auslöser der Aufgabe. Abgedichtet: `lauf()` setzt `SICHERUNG_ERSTER_TAG` auf `None` und `SICHERUNG_ZIEL` in den Wegwerf-Ordner, `sicherungslauf()` lenkt mit `--sicherung-ziel` um; unter `D:\Trading-Sicherung` landet nichts. `--mutanten` fährt erst die Grundlinie, dann `MUTANTEN` auf Kopien von `abendkontrolle.py` und `MUTANTEN_AUFGABE` auf Kopien der `.ps1` (als Text); jeder Mutant nennt die Fälle, die rot werden müssen | unter Windows `PYTHONIOENCODING=utf-8 python -X utf8 abendkontrolle_dockerhost_angriffe.py [--mutanten]`; Exit 0 ohne Abweichung, sonst 2 |
Was die Kette nicht prüft:
- Ob das echte `ssh.exe` die Bytes eines Archivs unverändert lässt. Jede Abweichung fiele als falsche Summe auf; den Nachweis bringt erst das erste echte Holen.
- Dass `cmd.exe` stdout der `.cmd`-Attrappe unberührt durchreicht, ist abgeleitet; der Fall mit CR, LF und Strg-Z im Archiv zeigt es.
- Die Units des Hosts, SELinux, das Packen auf dem Host, das Zurückspielen und ob die Kopie auf D: noch liegt; Veeam und die VHDX auch nicht.
- Ein von Hand verändertes Archiv neben passender Summendatei auf D:: `abendkontrolle.py` hasht nicht nach. Das zeigt erst `sha256sum -c` im Tagesordner.
- Eine Bestätigung belegt nur, dass der Absender die Summe des Hosts kennt, nicht, dass er geholt hat; derselbe Schlüssel liest die Summe.
- `abendkontrolle_mutanten.py` zählt jeden Exit ungleich 0 des Treibers als gefangen und zeigt je Mutant nur die ersten zwei `FEHL`-Zeilen. Dass der gemeinte Fall rot wird, belegen dort nur diese Zeilen und der Rot-Nachweis im Plan.
- Ohne eigenen Mutanten: `chmod 0644` der JSON-Datei auf dem Host, die Prüfung vor der Meldung (nur ein Fall belegt sie) und die Spanne auf der Antwort von Alpaca (kein Fall fährt den Netzweg).
- Ein `--tag` in falscher Form: Bis 0.39.0 prüfte der Fall dazu nur, dass keine Sicherung geholt wird, und die Abendkontrolle davor schrieb eine Notiz mit „gescheitert“, bei `../…` außerhalb von `Inbox/`. Seit 0.39.1 endet der Lauf gleich mit Exit 2, und der Fall `ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh` prüft das (Abschnitt „Paket NT“ unten).
## Paket NT (Trading Plattform 0.39.1): Sicherung bei Hostausfall, `--tag` früh geprüft
Seit Paket NT bricht `abendkontrolle.py` die Sicherung ab, wenn der Docker-Host nicht erreichbar ist, und prüft `--tag`, bevor es etwas anfängt. Plan im Projekt: `docs/plans/2026-10-11-paket-nt-nachtraege/` (Task 729 und 739); Codedoku dort in `docs/code/deployment.md`, Unterabschnitt „Sicherung (seit 0.39.0, Paket E9)“, Absatz „Abholen unter Windows“.
- **Wirkt ab dem nächsten Start der Windows-Aufgabe,** nicht erst beim Einspielen: Sie führt `abendkontrolle.py` aus diesem Ordner aus, abends und fünf Minuten nach jeder Anmeldung. Solange `SICHERUNG_ERSTER_TAG` `None` ist, holt sie keine Sicherung; sofort wirkt nur die frühe Prüfung von `--tag`.
- **Host nicht erreichbar:** Endet `sicherung-summe` oder `sicherung-bestaetigt` mit ssh-Exit 255 oder im Zeitlimit `SSH_TIMEOUT_S` (120 s), schreibt der Lauf genau eine Zeile „… **gescheitert** #needs-review. Docker-Host nicht erreichbar (…), Sicherung abgebrochen. … Der nächste Lauf versucht es erneut.“ in die Notiz von heute und endet mit Exit 1. Fertige Tage behalten ihre Zeilen; was ein angefangener Tag schon hat, steht hinter „Bis dahin am …“.
- **Abbruch im Archiv:** Exit 255 oder Zeitlimit `SICHERUNG_TIMEOUT_S` (600 s) bei `sicherung` lässt nur diese Instanz scheitern, mit „Archiv nicht geholt (…)“; der Lauf macht weiter.
- **`--tag`** außer der Form `JJJJ-MM-TT`, ohne gültiges Datum oder leer: eine Zeile auf stderr, Exit 2, keine Notiz, kein `lauf.log`, kein ssh, kein WSL.
- `abendkontrolle_wsl.py` nennt im Kopf-Docstring die zwei neuen `fehler`-Gründe aus `abendkontrolle-host.sh` (Summendatei oder Bestätigung nicht lesbar); sein Verhalten ändert sich nicht.
- Stand 0.39.1: Der Treiber hat 39 Fälle und 57 Mutanten. Weicht die Zählzeile eines Laufs davon ab, gilt sie.
| Datei | Was sich ändert | Aufruf |
|---|---|---|
| `abendkontrolle.py` | `ssh_lauf` erkennt den Ausfall (`HostNichtErreichbar`); `hole_sicherungen` bricht bei Summe und Bestätigung ab und schreibt die eine Zeile, beim Archiv scheitert nur die Instanz; `main` prüft `--tag` über `tag_gueltig` gleich nach dem Zerlegen, `sicherungstage` prüft den Tag nicht mehr selbst; der Kommentar über `SICHERUNG_NACHHOLEN_TAGE` beginnt nicht mehr mit „(S3).“; der Docstring von `ssh_lauf` sagt, dass Exit 255 bei einem Signal auf dem Host nur für OpenSSH-portable 9.5p1 nachgelesen ist, nicht für Win32-OpenSSH 9.5p2 | `python abendkontrolle.py --quelle dockerhost --quelle-ibkr dockerhost [--tag JJJJ-MM-TT]` |
| `abendkontrolle_dockerhost_angriffe.py` | Die `ssh.exe`-Attrappe kennt drei Störungen mehr: `ANGRIFF_HOST_WEG=<n>` lässt jeden Aufruf nach den ersten n mit Exit 255 enden wie ssh ohne Host, `ANGRIFF_SICHERUNG=langsam` hält jeden Sicherungsbefehl 5 s auf, `ANGRIFF_SICHERUNG=archiv_unlesbar` endet beim Archiv wie `abendkontrolle-lesen.sh` ohne Leserecht (Exit 1). `lauf()` nimmt `ANGRIFF_HOST_WEG` aus der Umgebung, `sicherungslauf()` setzt es über den Schalter `host_weg`. Neue Fälle: `ein_unlesbares_archiv_auf_dem_host_scheitert_nur_fuer_seine_instanz`, `ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz`, `ein_host_der_mitten_im_lauf_wegfaellt_laesst_die_zeilen_davor_stehen`, `ein_zeitlimit_beim_host_zaehlt_wie_ein_unerreichbarer_host`, `ein_zeitlimit_beim_archiv_laesst_nur_diese_instanz_scheitern`, `eine_bestaetigung_ohne_host_bricht_die_sicherung_ab` (Task 739) und `kein_kommentar_beginnt_mit_s3`. `ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh` ersetzt `ein_tag_ausser_der_form_holt_keine_sicherung`; `eine_abgebrochene_uebertragung_hinterlaesst_nichts` erwartet einen Grund mit dem Befehl. In `MUTANTEN` ersetzen E729-01 bis E729-15 und E729-25 die Mutanten „Tag ungeprueft“ und „Datum ungeprueft“; dazu kommt E739-01, und „Exit des Archivs egal“ zielt jetzt auf den Fall mit dem unlesbaren Archiv | unter Windows `PYTHONIOENCODING=utf-8 python -X utf8 abendkontrolle_dockerhost_angriffe.py [--mutanten]`; Exit 0 ohne Abweichung, sonst 2 |
| `abendkontrolle_wsl.py` | nur der Kopf-Docstring: die zwei neuen `fehler`-Gründe | wie im Abschnitt Paket E9 |
Was die Kette nicht prüft:
- Ob Exit 255 unter Win32-OpenSSH 9.5p2 auch kommt, wenn auf dem Host ein Signal `abendkontrolle-lesen.sh` beendet. Laut Quelltext von OpenSSH-portable 9.5p1 ja; dann hieße die Zeile bei Summe oder Bestätigung „Docker-Host nicht erreichbar“, obwohl er erreichbar war.
- Lässt sich `lauf.log` nicht schreiben, fehlt auch die Zeile in der Notiz; übrig bleibt Exit 1 (kritische Prüfung NT, Befund 3, so seit E9). Ein Fall dazu fehlt, die Abhilfe steht im `todo.md` des Projekts.
- Bricht das Archiv der letzten Instanz des letzten Tags ab, weil der Host gerade wegfällt, folgt keine Frage nach einer Summe mehr; die Zeile nennt dann nur „Archiv nicht geholt (… Exit 255 …)“, nicht „Docker-Host nicht erreichbar“.
- Bricht das erste Archiv eines Tags ab, bleibt der leere Tagesordner auf D: zurück (so seit E9).
- Ohne eigenen Fall: die ASCII-Darstellung des Tags in der Meldung zu `--tag` (ein Mutant `!a` nach `!r` überlebte), und „kein WSL“ bei falschem `--tag` misst der Fall nur mit `--quelle dockerhost`.
- Ein Abend mit ausgeschaltetem Host über das echte `ssh.exe`.
## Paket AB (2026-10-07): Abbau des Restbestands nach dem Netzschalter-Abriss ## 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/`. 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/`.
@@ -29,13 +29,25 @@ Augenblick schreibt, schuetzt das nicht (Zeitfenster Lesen bis Ersetzen, Millise
Annahme, benannt: Die Uhr dieses Rechners geht nach Berliner Zeit (tzutil: W. Europe Standard Time, Annahme, benannt: Die Uhr dieses Rechners geht nach Berliner Zeit (tzutil: W. Europe Standard Time,
gelesen am 2026-09-24); ohne das Paket tzdata kennt Python unter Windows keine Zeitzonen-Datenbank. gelesen am 2026-09-24); ohne das Paket tzdata kennt Python unter Windows keine Zeitzonen-Datenbank.
Seit Paket E9 holt die Aufgabe fuer jeden Teil mit Quelle dockerhost auch die Sicherung des Hosts ab
(hole_sicherungen): je Kalendertag der letzten SICHERUNG_NACHHOLEN_TAGE die Summe des Hosts fragen; wo
die Summendatei auf D: fehlt oder abweicht, <tag>-<instanz>.tar ueber denselben Schluessel holen, Summe
pruefen, nach SICHERUNG_ZIEL/<tag>/<instanz>.tar umbenennen, beim Host bestaetigen, zuletzt
<instanz>.tar.sha256 schreiben; eine Zeile in der Notiz je Tag mit Neuem. Solange SICHERUNG_ERSTER_TAG
None ist, holt sie nichts. Den Abend-Ausloeser legt abendkontrolle_einrichten.ps1 in Ortszeit an (S10).
Seit Paket NT (Quorum 4): Endet die Frage nach einer Summe oder die Bestaetigung mit ssh-Exit 255 oder
im Zeitlimit, ist der Docker-Host nicht erreichbar, und die Sicherung hoert sofort auf: eine Zeile in der
Notiz von heute, Exit 1. Bricht nur das Archiv so ab, scheitert allein diese Instanz. --tag ausser der
Form JJJJ-MM-TT oder ohne gueltiges Datum endet gleich nach dem Zerlegen mit Exit 2, ohne Notiz und ssh.
Aufruf von Hand: python abendkontrolle.py [--tag JJJJ-MM-TT] [--vault D:\\Claude] [--ohne-wsl <json-datei>] Aufruf von Hand: python abendkontrolle.py [--tag JJJJ-MM-TT] [--vault D:\\Claude] [--ohne-wsl <json-datei>]
[--quelle wsl|dockerhost] [--quelle-ibkr wsl|dockerhost] [--quelle wsl|dockerhost] [--quelle-ibkr wsl|dockerhost] [--sicherung-ziel <ordner>]
""" """
import argparse import argparse
import hashlib
import json import json
import os import os
import re
import subprocess import subprocess
import sys import sys
import traceback import traceback
@@ -44,6 +56,7 @@ from datetime import datetime
from datetime import time from datetime import time
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from time import monotonic
VAULT = Path(r"D:\Claude") VAULT = Path(r"D:\Claude")
PROJEKT_TEIL = Path("Code Projekte") / "active - Trading Plattform" PROJEKT_TEIL = Path("Code Projekte") / "active - Trading Plattform"
@@ -82,6 +95,48 @@ KOEPFE = {
} }
# Seit Paket E9 (Trading Plattform 0.39.0): Der Host sichert werktags 16:45 New Yorker Zeit je
# Instanz Register, data/paper und artifacts/paper (sicherung-host.sh). Diese Aufgabe holt fuer
# jeden Teil mit Quelle dockerhost die Sicherung ueber denselben Schluessel ab (abendkontrolle-lesen.sh
# mit sicherung-summe, sicherung und sicherung-bestaetigt), prueft die Summe und legt sie unter
# SICHERUNG_ZIEL/<tag>/<instanz>.tar samt <instanz>.tar.sha256 ab. Massstab ist die aktuelle Summe des
# Hosts: Fehlt die Summendatei auf D: oder weicht sie ab, holt der Lauf neu und ersetzt die alte Fassung
# (Plankritik Runde 1, Befunde 5 und 24). Sonst loescht er auf D: nichts.
SICHERUNG_ZIEL = Path(r"D:\Trading-Sicherung")
# Der erste Tag, den die Aufgabe abholt; None holt nichts. Gesetzt beim Einspielen von E9 (S8): Diese
# Datei laeuft schon vorher jeden Abend, der Host kennt die Lesebefehle erst danach.
SICHERUNG_ERSTER_TAG: date | None = None
# So viele Kalendertage vor heute fragt jeder Lauf, dazu heute. Das Fenster liegt in der Aufbewahrung
# des Hosts: Er haelt die neuesten zehn Sicherungstage, nur werktags gesichert, also gut zwei Kalenderwochen.
# Die Sicherung entsteht 16:45 New Yorker Zeit, in Berlin meist 22:45, vom 25.10. bis 31.10.2026
# und vom 14.3. bis 27.3.2027 schon 21:45. Die Aufgabe startet 23:15 Ortszeit (S10); fehlt die
# Sicherung von heute trotzdem noch, ist das kein Fehlschlag.
SICHERUNG_NACHHOLEN_TAGE = 10
# Je Archiv; Summe und Bestaetigung nehmen SSH_TIMEOUT_S. Die Aufgabe selbst endet nach 45 Minuten.
SICHERUNG_TIMEOUT_S = 600
# Zeitbudget je Lauf fuer die Sicherung, geprueft vor jeder Instanz: Ist es um, faengt der Lauf nichts
# mehr an, und der naechste holt den Rest (Plankritik Runde 1, Befund 12).
SICHERUNG_BUDGET_S = 1500
SICHERUNG_KOPF = "Sicherung Trading Plattform (automatisch)"
# Eine Zeile im Format von sha256sum: 64 Hexzeichen in Kleinbuchstaben, zwei Leerzeichen, Name, LF.
SUMMENZEILE = re.compile(r"([0-9a-f]{64}) (\S+)\n")
TAGFORM = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")
def tag_gueltig(tag: str) -> bool:
"""Ist tag ein Datum JJJJ-MM-TT, das es gibt, nur mit ASCII-Ziffern? Seit Paket NT fuer --tag in main.
TAGFORM allein liesse 2026-13-05 durch, date.fromisoformat allein seit Python 3.11 auch 20261005.
"""
if not TAGFORM.fullmatch(tag):
return False
try:
date.fromisoformat(tag)
except ValueError:
return False
return True
def faellige_tage(jetzt: datetime, ablage: Path, erster_tag: date = ERSTER_TAG, endung: str = "") -> list[str]: def faellige_tage(jetzt: datetime, ablage: Path, erster_tag: date = ERSTER_TAG, endung: str = "") -> list[str]:
tage = [] tage = []
for zurueck in range(NACHHOLEN_TAGE, -1, -1): for zurueck in range(NACHHOLEN_TAGE, -1, -1):
@@ -155,6 +210,271 @@ def fahre_dockerhost(tag: str, teil: str = "alpaca") -> dict:
raise RuntimeError(f"keine JSON-Zeile vom Docker-Host (Exit {lauf.returncode}; {' | '.join(fehler)[:300]})") raise RuntimeError(f"keine JSON-Zeile vom Docker-Host (Exit {lauf.returncode}; {' | '.join(fehler)[:300]})")
def ssh_aufruf(befehl: str) -> list[str]:
"""Die Befehlszeile fuer einen Befehl der Sicherung, mit denselben Optionen wie fahre_dockerhost."""
return [
SSH,
"-p", SSH_PORT,
"-i", str(SSH_SCHLUESSEL),
"-o", "BatchMode=yes",
"-o", "IdentitiesOnly=yes",
"-o", "StrictHostKeyChecking=yes",
"-o", f"UserKnownHostsFile={SSH_BEKANNTE}",
"-o", "ConnectTimeout=20",
SSH_ZIEL,
befehl,
]
def fehlerzeile(lauf: subprocess.CompletedProcess) -> str:
"""Exit und die letzten Zeilen von stderr, gekuerzt."""
fehler = (lauf.stderr or b"").decode("utf-8", errors="replace").strip().splitlines()[-3:]
return f"Exit {lauf.returncode}; {' | '.join(fehler)[:300]}"
def summe_von(datei: Path) -> str:
"""SHA-256 einer Datei in Kleinbuchstaben, stueckweise gelesen."""
summe = hashlib.sha256()
with datei.open("rb") as strom:
for stueck in iter(lambda: strom.read(1 << 20), b""):
summe.update(stueck)
return summe.hexdigest()
class HostNichtErreichbar(Exception):
"""ssh selbst scheiterte (Exit 255) oder ein Befehl der Sicherung lief in sein Zeitlimit (seit Paket NT).
Beim Archiv macht hole_sicherung daraus den Fehlschlag dieser einen Instanz.
"""
def ssh_lauf(befehl: str, timeout, stdout=None) -> subprocess.CompletedProcess:
"""Ein Befehl der Sicherung beim Host. Mit stdout (offene Datei) geht die Ausgabe dorthin.
Eigene Funktion, damit die Angriffsfaelle Zeitlimit und Dauer jedes Befehls sehen. Seit Paket NT
(Quorum 4, G1): abendkontrolle-lesen.sh endet mit 0 bis 4. Exit 255 meldet ssh selbst: keine
Verbindung oder Anmeldung, laut Quelltext von OpenSSH-portable 9.5p1 auch ein Befehl, den auf dem
Host ein Signal beendet (fuer Win32-OpenSSH 9.5p2 auf diesem Rechner nicht nachgelesen). Er und ein
abgelaufenes Zeitlimit heissen: Der Docker-Host ist nicht erreichbar (HostNichtErreichbar).
"""
try:
if stdout is None:
lauf = subprocess.run(
ssh_aufruf(befehl), capture_output=True, timeout=timeout, creationflags=KEIN_FENSTER,
)
else:
lauf = subprocess.run(
ssh_aufruf(befehl), stdout=stdout, stderr=subprocess.PIPE, timeout=timeout,
creationflags=KEIN_FENSTER,
)
except subprocess.TimeoutExpired:
raise HostNichtErreichbar(f"{befehl}: Zeitlimit {timeout} s abgelaufen") from None
if lauf.returncode == 255:
raise HostNichtErreichbar(f"{befehl}: {fehlerzeile(lauf)}")
return lauf
def summe_auf_d(ordner: Path, instanz: str) -> str | None:
"""Die Summe aus <instanz>.tar.sha256 in ordner, wenn sie dort richtig steht und das Archiv daneben liegt.
Die Summendatei schreibt nur hole_sicherung, nach Pruefung und Bestaetigung: Sie steht fuer ein
geprueftes und bestaetigtes Archiv. Das Archiv selbst hasht dieser Lauf nicht nach (Plankritik
Runde 1, Befund 21); sonst laese jeder Lauf alle Archive des Fensters.
"""
archiv = ordner / f"{instanz}.tar"
try:
treffer = SUMMENZEILE.fullmatch((ordner / f"{instanz}.tar.sha256").read_bytes().decode("ascii"))
except (OSError, UnicodeDecodeError):
return None
if treffer is None or treffer.group(2) != archiv.name or not archiv.is_file():
return None
return treffer.group(1)
def sicherungstage(jetzt: datetime, tag: str | None = None) -> list[str]:
"""Die Tage, fuer die der Lauf den Host fragt, der aelteste zuerst.
Ohne SICHERUNG_ERSTER_TAG keiner. Mit tag (--tag) nur dieser. Sonst jeder Kalendertag der letzten
SICHERUNG_NACHHOLEN_TAGE bis heute ab SICHERUNG_ERSTER_TAG, auch Samstag und Sonntag (ein
nachgeholter Lauf des Hosts traegt das Datum des Nachholens) und auch heute (Plankritik Runde 1,
Befunde 8, 16, 18 und 19). Ob das Ergebnis der Abendkontrolle fuer den Tag schon liegt, spielt
keine Rolle.
"""
erster = SICHERUNG_ERSTER_TAG
if erster is None:
return []
if tag is not None:
# Der Tag wird Teil eines Pfads und eines Befehls auf dem Host. Seine Form (JJJJ-MM-TT, ein Datum,
# das es gibt) prueft seit Paket NT main gleich nach dem Zerlegen der Argumente (tag_gueltig).
return [tag]
tage = []
for zurueck in range(SICHERUNG_NACHHOLEN_TAGE, -1, -1):
kandidat = jetzt.date() - timedelta(days=zurueck)
if kandidat >= erster:
tage.append(kandidat.isoformat())
return tage
def summe_vom_host(name: str) -> str | None:
"""Die Summe, die der Host fuer <name> fuehrt; None bei Exit 3 (dort liegt keine Sicherung)."""
lauf = ssh_lauf(f"sicherung-summe {name}", SSH_TIMEOUT_S)
if lauf.returncode == 3:
return None
if lauf.returncode != 0:
raise RuntimeError(f"Summe nicht gelesen ({fehlerzeile(lauf)})")
treffer = SUMMENZEILE.fullmatch(lauf.stdout.decode("ascii", errors="replace"))
if treffer is None or treffer.group(2) != f"{name}.tar":
raise RuntimeError("Summendatei des Hosts in unerwarteter Form")
return treffer.group(1)
def ohne_sicherung_still(tag: str, jetzt: datetime) -> bool:
"""Ist Exit 3 fuer diesen Tag kein Fehlschlag? Ja fuer heute (und spaeter) und fuer Samstag und Sonntag.
Heute kann die Sicherung des Hosts noch ausstehen; am Wochenende sichert er nur, wenn er einen
verpassten Lauf nachholt. Fehlt sie an einem vergangenen Werktag, ist das ein Fehlschlag.
"""
kandidat = date.fromisoformat(tag)
return kandidat >= jetzt.date() or kandidat.weekday() >= 5
def sichere(tag: str, instanz: str, ziel: Path, jetzt: datetime) -> tuple[str, bool]:
"""Eine Instanz eines Tags. Liefert ihren Teil der Zeile und ob geholt wurde; Fehlschlag: Ausnahme.
Massstab ist die aktuelle Summe des Hosts (Plankritik Runde 1, Befunde 5 und 24). Steht dieselbe in
der Summendatei auf D:, ist der Tag geholt und bestaetigt. Sonst holt der Lauf neu und ersetzt die
alte Fassung, etwa nachdem der Host den Tag ein zweites Mal gesichert hat.
"""
name = f"{tag}-{instanz}"
summe = summe_vom_host(name)
if summe is None:
if ohne_sicherung_still(tag, jetzt):
return f"{instanz} auf dem Docker-Host noch nicht gesichert", False
raise RuntimeError(f"auf dem Docker-Host liegt fuer {name} noch keine Sicherung")
if summe_auf_d(ziel / tag, instanz) == summe:
return f"{instanz} lag schon vollständig", False
return hole_sicherung(tag, instanz, ziel, summe), True
def hole_sicherung(tag: str, instanz: str, ziel: Path, summe: str) -> str:
"""Holt die Sicherung <tag>-<instanz> mit der Summe des Hosts, prueft, legt ab und bestaetigt.
Reihenfolge: Archiv in die Nachbardatei <instanz>.tar.teil holen, Summe pruefen, eine alte
<instanz>.tar.sha256 entfernen, auf <instanz>.tar umbenennen (ersetzt eine alte Fassung), beim
Host bestaetigen, zuletzt <instanz>.tar.sha256 schreiben. Erst die Summendatei macht die Ablage
vollstaendig: Scheitert die Bestaetigung, fehlt sie, und der naechste Lauf holt und bestaetigt
erneut. Bei falscher Summe wird nichts umbenannt und nichts bestaetigt. Jeder Fehlschlag ist
eine Ausnahme mit dem Grund. Liefert den Teil der Zeile.
"""
name = f"{tag}-{instanz}"
ordner = ziel / tag
ordner.mkdir(parents=True, exist_ok=True)
teil = ordner / f"{instanz}.tar.teil"
archiv = ordner / f"{instanz}.tar"
summendatei = ordner / f"{instanz}.tar.sha256"
try:
with teil.open("wb") as strom:
try:
lauf = ssh_lauf(f"sicherung {name}", SICHERUNG_TIMEOUT_S, strom)
except HostNichtErreichbar as abbruch:
# Seit Paket NT (Plankritik, Befund 1): Mitten im Archiv heissen Zeitlimit und Exit 255 nicht,
# dass der Host fehlt; er hat die Summe gerade beantwortet. Nur diese Instanz scheitert, und
# ein einzelnes zu langsames Archiv haelt nicht jeden Lauf an derselben Stelle an. Ist der
# Host wirklich weg, merkt es die naechste Frage nach einer Summe.
raise RuntimeError(f"Archiv nicht geholt ({abbruch})") from None
if lauf.returncode != 0:
raise RuntimeError(f"Archiv nicht geholt ({fehlerzeile(lauf)})")
if summe_von(teil) != summe:
raise RuntimeError("Summe des geholten Archivs ist nicht die des Hosts; nicht abgelegt, nicht bestaetigt")
summendatei.unlink(missing_ok=True)
os.replace(teil, archiv)
finally:
try:
teil.unlink(missing_ok=True)
except OSError:
pass
lauf = ssh_lauf(f"sicherung-bestaetigt {name} {summe}", SSH_TIMEOUT_S)
if lauf.returncode != 0:
raise RuntimeError(f"abgelegt, aber nicht bestaetigt ({fehlerzeile(lauf)})")
neu = ordner / f"{instanz}.tar.sha256.neu"
neu.write_bytes(f"{summe} {archiv.name}\n".encode("ascii"))
os.replace(neu, summendatei)
return f"{instanz} abgelegt und bestätigt ({archiv.stat().st_size} Bytes)"
def hole_sicherungen(jetzt: datetime, vault: Path, ablage: Path, ziel: Path, instanzen: list[str],
frist: float, tag: str | None = None) -> int:
"""Je Tag des Fensters und Instanz: Summe des Hosts fragen und holen, wo D: sie nicht hat.
Eine Zeile in der Notiz je Tag, an dem etwas geholt wurde, etwas scheiterte oder das Zeitbudget
endete; ein Fehlschlag traegt #needs-review. Ein Tag ohne Neues bekommt keine Zeile. frist ist der
Stand von monotonic(), an dem SICHERUNG_BUDGET_S ab dem Start des Laufs um ist (main); danach
faengt der Lauf keine Instanz mehr an, und der naechste holt den Rest.
"""
if not instanzen:
return 0
code = 0
erschoepft = False
for kandidat in sicherungstage(jetzt, tag):
if erschoepft:
break
uhrzeit = jetzt.strftime("%H:%M") if kandidat == jetzt.date().isoformat() else f"{jetzt:%Y-%m-%d %H:%M}"
stuecke = []
gescheitert = False
getan = False
for instanz in instanzen:
if monotonic() >= frist:
erschoepft = getan = True
stuecke.append(
f"{instanz} nicht angefangen: Zeitbudget des Laufs ({SICHERUNG_BUDGET_S} s) erschöpft, "
"der nächste Lauf holt den Rest"
)
logge(ablage, f"sicherung {kandidat}-{instanz}: Zeitbudget erschoepft")
continue
try:
stueck, geholt = sichere(kandidat, instanz, ziel, jetzt)
stuecke.append(stueck)
if geholt:
getan = True
logge(ablage, f"sicherung {kandidat}-{instanz}: abgelegt und bestaetigt")
except HostNichtErreichbar as ausnahme:
# Seit Paket NT (Quorum 4, G1): Ohne Host scheiterte sonst jeder Tag des Fensters mit eigener
# Zeile, auch in neu angelegten Notizen von Wochenenden. Die Sicherung hoert hier auf: eine
# Zeile in der Notiz von heute, Exit 1. Fertige Tage behalten ihre Zeilen; was der
# angefangene Tag schon hat, nennt diese Zeile. Auf D: entsteht danach nichts mehr.
logge(ablage, f"sicherung {kandidat}-{instanz}: Docker-Host nicht erreichbar ({ausnahme}), Sicherung abgebrochen")
zeile = (
f"- {jetzt:%H:%M} Uhr — {SICHERUNG_KOPF}: **gescheitert** #needs-review. Docker-Host nicht "
f"erreichbar ({str(ausnahme)[:200]}), Sicherung abgebrochen."
)
if stuecke:
zeile += f" Bis dahin am {kandidat}: {'; '.join(stuecke)}."
zeile += " Der nächste Lauf versucht es erneut."
try:
schreibe_notiz(vault, jetzt.date().isoformat(), zeile)
except Exception as zweite:
logge(ablage, f"sicherung: Notiz nicht geschrieben {zweite!r}")
return 1
except Exception as ausnahme:
gescheitert = True
stuecke.append(f"{instanz} gescheitert ({type(ausnahme).__name__}: {str(ausnahme)[:200]})")
logge(ablage, f"sicherung {kandidat}-{instanz}: gescheitert {ausnahme!r}")
if not (gescheitert or getan):
continue
kopf = f"- {uhrzeit} Uhr — {SICHERUNG_KOPF}"
if gescheitert:
code = 1
zeile = f"{kopf}: **gescheitert** #needs-review. {'; '.join(stuecke)}. Der nächste Lauf versucht es erneut."
else:
zeile = f"{kopf}: {'; '.join(stuecke)}."
try:
schreibe_notiz(vault, kandidat, zeile)
except Exception as ausnahme:
code = 1
logge(ablage, f"sicherung {kandidat}: Notiz nicht geschrieben {ausnahme!r}")
return code
def zeile_fuer(ergebnis: dict, uhrzeit: str) -> str: def zeile_fuer(ergebnis: dict, uhrzeit: str) -> str:
kopf = f"- {uhrzeit} Uhr — Abendkontrolle Trading Plattform (automatisch)" kopf = f"- {uhrzeit} Uhr — Abendkontrolle Trading Plattform (automatisch)"
befunde = ergebnis.get("befunde") or [] befunde = ergebnis.get("befunde") or []
@@ -284,7 +604,21 @@ def main(argv=None) -> int:
"--quelle-ibkr", choices=QUELLEN, "--quelle-ibkr", choices=QUELLEN,
help="die Quelle fuer den Teil ibkr; ohne Angabe dieselbe wie --quelle", help="die Quelle fuer den Teil ibkr; ohne Angabe dieselbe wie --quelle",
) )
zerleger.add_argument(
"--sicherung-ziel",
help="Ordner fuer die Sicherungen statt SICHERUNG_ZIEL (Angriffsfaelle)",
)
argumente = zerleger.parse_args(argv) argumente = zerleger.parse_args(argv)
# Seit Paket NT (Quorum 4): --tag gleich hier pruefen. Der Tag wird Name der Notiz, Pfad unter
# SICHERUNG_ZIEL und Teil eines Befehls auf dem Host; vorher legte ein Tag ausser der Form
# Inbox/<tag>.md an, mit ../ auch ausserhalb von Inbox/. Leer zaehlt als ausser der Form. Die
# Ausgabe ist ASCII (!a), damit kein Zeichen an der Kodierung von stderr scheitert.
if argumente.tag is not None and not tag_gueltig(argumente.tag):
print(f"abendkontrolle: --tag {argumente.tag!a} ist kein Datum JJJJ-MM-TT", file=sys.stderr)
return 2
# Seit Paket E9: Das Zeitbudget der Sicherung zaehlt ab hier, also mit der Zeit fuer die
# Ergebnisse (bis zu zwoelf Abfragen mit je SSH_TIMEOUT_S; Plankritik Runde 2, Befund 1).
frist = monotonic() + SICHERUNG_BUDGET_S
vault = Path(argumente.vault) vault = Path(argumente.vault)
ablage = vault / ABLAGE_TEIL ablage = vault / ABLAGE_TEIL
jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now() jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now()
@@ -313,6 +647,21 @@ def main(argv=None) -> int:
logge(ablage, f"{vorsatz}abgebrochen: {type(ausnahme).__name__}") logge(ablage, f"{vorsatz}abgebrochen: {type(ausnahme).__name__}")
except Exception: except Exception:
pass pass
# Seit Paket E9: die Sicherung jedes Teils mit Quelle dockerhost, nach den Ergebnissen beider
# Teile und unabhaengig davon, ob ein Ergebnis gescheitert ist.
instanzen = [
teil for teil in teile
if ((argumente.quelle_ibkr or argumente.quelle) if teil == "ibkr" else argumente.quelle) == "dockerhost"
]
ziel = Path(argumente.sicherung_ziel) if argumente.sicherung_ziel else SICHERUNG_ZIEL
try:
code = max(code, hole_sicherungen(jetzt, vault, ablage, ziel, instanzen, frist, argumente.tag))
except Exception as ausnahme:
code = 1
try:
logge(ablage, f"sicherung abgebrochen: {type(ausnahme).__name__}: {ausnahme}")
except Exception:
pass
return code return code
@@ -5,6 +5,7 @@ Grundlinie; ohne Netz (--ohne-broker/--ohne-kerzen, Kalender und Journal aus Dat
Auswertung fuer die Brokerzeile) und ohne Meldung (--ohne-meldung). Faelle an zwei Listenpositionen, Auswertung fuer die Brokerzeile) und ohne Meldung (--ohne-meldung). Faelle an zwei Listenpositionen,
wo die Reihenfolge zaehlen kann (AI/Regelwerk/pruefwerkzeuge.md, Abschnitt 3). wo die Reihenfolge zaehlen kann (AI/Regelwerk/pruefwerkzeuge.md, Abschnitt 3).
Aufruf in WSL (Venv) und unter Windows: python abendkontrolle_angriffe.py Aufruf in WSL (Venv) und unter Windows: python abendkontrolle_angriffe.py
Nur die Faelle zur Sicherung (Paket E9, Task 726), in WSL: python abendkontrolle_angriffe.py --nur-sicherung
Exit 0, wenn jeder Fall so ausgeht wie erwartet; sonst 1 mit der Liste der Abweichungen. Exit 0, wenn jeder Fall so ausgeht wie erwartet; sonst 1 mit der Liste der Abweichungen.
""" """
@@ -811,12 +812,269 @@ def ibkr_windows_faelle() -> None:
f"ibkr {ibkr} alpaca {alpaca_tage}") f"ibkr {ibkr} alpaca {alpaca_tage}")
# --- Sicherung des vorigen Handelstags (Paket E9, Task 726), WSL-Teil ------------------------------
# TAG ist ein Mittwoch, S_VORHER der Dienstag davor, S_DAVOR der Montag. Die Summen sind erfunden.
S_VORHER = "2026-09-22"
S_DAVOR = "2026-09-21"
S_ERSTER = "2026-09-14"
S_SUMME = "ab" * 32
S_ANDERE = "cd" * 32
S_KALENDER = [
{"date": S_DAVOR, "open": "09:30", "close": "16:00"},
{"date": S_VORHER, "open": "09:30", "close": "16:00"},
] + KALENDER_TAG
def s_eintraege(anders: dict | None = None, ohne: tuple = ()) -> dict:
"""Beide Instanzen an S_DAVOR und S_VORHER mit Archiv und bestaetigt; anders ersetzt, ohne loescht."""
eintraege = {
f"{tag}-{instanz}": {"summe": S_SUMME, "bestaetigt": S_SUMME, "archiv": True}
for tag in (S_DAVOR, S_VORHER)
for instanz in ("alpaca", "ibkr")
}
for name in ohne:
del eintraege[name]
eintraege.update(anders or {})
return eintraege
def s_stand(eintraege: dict | None = None, **oben) -> dict:
"""Ein Stand wie von abendkontrolle-host.sh; oben ersetzt oder ergaenzt Schluessel der obersten Ebene."""
stand = {"erster_tag": S_ERSTER, "eintraege": s_eintraege() if eintraege is None else eintraege}
stand.update(oben)
return stand
def fahre_sicherung(wurzel: Path, ziel: Path, teil: str, *, schalter: bool = True, tag: str = TAG,
meldung: bool = False, env: dict | None = None) -> dict:
"""Ein Lauf eines Teils mit --sicherung; ziel ist der Projektordner (alpaca) oder der Betriebsordner (ibkr)."""
befehl = [
sys.executable, str(HIER / "abendkontrolle_wsl.py"), "--tag", tag,
"--kalender", str(wurzel / "kalender.json"), "--journal", str(wurzel / "journal.txt"),
"--dienste", str(wurzel / "dienste.json"),
]
if teil == "ibkr":
befehl += ["--teil", "ibkr", "--ibkr-ordner", str(ziel), "--projekt", str(wurzel),
"--ibkr-konto", str(wurzel / "konto.json"), "--jetzt", NACH_SCHLUSS]
else:
befehl += ["--projekt", str(ziel), "--ohne-kerzen", "--ohne-broker"]
if schalter:
befehl += ["--sicherung", str(wurzel / "sicherung.json")]
if not meldung:
befehl.append("--ohne-meldung")
umgebung = {k: v for k, v in os.environ.items() if k != "ABENDKONTROLLE_AUSWERTUNG"}
umgebung.update(env or {})
lauf = subprocess.run(befehl, capture_output=True, text=True, env=umgebung, timeout=300)
try:
ergebnis = json.loads(lauf.stdout.strip().splitlines()[-1])
except (IndexError, json.JSONDecodeError):
return {"befunde": [f"KEINE JSON-AUSGABE exit {lauf.returncode}: {lauf.stderr[-300:]}"], "exit": lauf.returncode}
ergebnis["_exit"] = lauf.returncode
return ergebnis
def sicherung_faelle() -> None:
def fall(name, stand, *, befund, teil="alpaca", kalender=S_KALENDER, anzahl=None, exit_code=0,
schalter=True, tag=TAG, auch=(), nicht=(), fakt=None):
"""stand: None heisst, die Datei fehlt; eine Zeichenkette wird roh geschrieben, sonst als JSON."""
with tempfile.TemporaryDirectory() as tmp:
wurzel = Path(tmp)
ziel = baue_ibkr(wurzel) if teil == "ibkr" else baue_projekt(wurzel)
(wurzel / "kalender.json").write_text(json.dumps(kalender), encoding="utf-8")
if isinstance(stand, str):
(wurzel / "sicherung.json").write_text(stand, encoding="utf-8")
elif stand is not None:
(wurzel / "sicherung.json").write_text(json.dumps(stand), encoding="utf-8")
erwarte("Sicherung " + name, fahre_sicherung(wurzel, ziel, teil, schalter=schalter, tag=tag),
befund=befund, anzahl=anzahl, exit_code=exit_code, auch=auch, nicht=nicht, fakt=fakt)
a = f"{S_VORHER}-alpaca"
i = f"{S_VORHER}-ibkr"
offen = {"summe": S_SUMME, "bestaetigt": None, "archiv": True}
# Grundlinie in beiden Teilen; ohne Schalter bleibt das Werkzeug, wie es war.
fall("Grundlinie alpaca", s_stand(), befund=None, anzahl=0,
fakt=lambda f, e: f.get("sicherung") == {"name": a, "erster_tag": S_ERSTER, "geprueft": True})
fall("Grundlinie ibkr", s_stand(), teil="ibkr", befund=None, anzahl=0,
fakt=lambda f, e: f.get("sicherung") == {"name": i, "erster_tag": S_ERSTER, "geprueft": True})
fall("ohne Schalter", s_stand(s_eintraege({a: offen})), schalter=False, befund=None, anzahl=0,
fakt=lambda f, e: "sicherung" not in f)
# Umgebung und Form der Datei
fall("Datei fehlt", None, befund="Sicherung nicht geprueft (FileNotFoundError", anzahl=1)
fall("Datei kein JSON", "<html>", befund="Sicherung nicht geprueft (JSONDecodeError", anzahl=1)
fall("Datei leer", "", befund="Sicherung nicht geprueft (JSONDecodeError", anzahl=1)
fall("Stand null", "null", befund="Sicherung: Stand ist kein Objekt", anzahl=1)
fall("Stand als Liste", [], befund="Sicherung: Stand ist kein Objekt", anzahl=1)
fall("eintraege als Liste", s_stand([]), befund="Sicherung: Stand in unerwarteter Form", anzahl=1)
fall("eintraege fehlt", {"erster_tag": S_ERSTER}, befund="Sicherung: Stand in unerwarteter Form", anzahl=1)
fall("erster_tag fehlt", {"eintraege": s_eintraege()}, befund="Sicherung: Stand in unerwarteter Form", anzahl=1)
fall("erster_tag als Zahl", s_stand(erster_tag=20260914), befund="Sicherung: erster_tag ist kein Datum", anzahl=1)
fall("erster_tag kein Datum", s_stand(erster_tag="morgen"), befund="Sicherung: erster_tag ist kein Datum", anzahl=1)
# Fehler, die abendkontrolle-host.sh in den Stand schreibt
fall("Host meldet fehler", {"fehler": "Ablage der Sicherung fehlt"},
befund="Sicherung nicht geprueft: Ablage der Sicherung fehlt", anzahl=1)
fall("fehler neben vollem Stand", s_stand(fehler="Ablage der Sicherung nicht lesbar"),
befund="Sicherung nicht geprueft: Ablage der Sicherung nicht lesbar", anzahl=1)
# Erster Sicherungstag (seit der Plankritik Runde 1): null und ein Tag nach TAG sind Befunde, sonst
# schwiege die Pruefung auf Dauer. Still bleibt nur ein voriger Handelstag vor erster_tag; TAG selbst
# als erster_tag ist erlaubt, und der erste Sicherungstag selbst wird geprueft.
fall("erster_tag null", s_stand({}, erster_tag=None),
befund="Sicherung nicht geprueft: auf dem Docker-Host fehlt erster-tag der Sicherung", anzahl=1,
fakt=lambda f, e: "sicherung" not in f
and not any("erster-tag" in h for h in e.get("hinweise", [])))
fall("erster_tag nach dem geprueften Tag", s_stand({}, erster_tag="2026-09-24"),
befund=f"Sicherung nicht geprueft: erster_tag 2026-09-24 liegt nach dem geprueften Tag {TAG}", anzahl=1,
fakt=lambda f, e: "sicherung" not in f)
fall("voriger Handelstag vor erster_tag", s_stand({}, erster_tag=TAG), befund=None, anzahl=0,
fakt=lambda f, e: f.get("sicherung", {}).get("geprueft") is False)
fall("erster_tag ist der vorige Handelstag", s_stand({}, erster_tag=S_VORHER),
befund=f"Sicherung {a} fehlt auf dem Docker-Host", anzahl=1)
# Eintrag des vorigen Handelstags
fall("Eintrag fehlt", s_stand(s_eintraege(ohne=(a,))), befund=f"Sicherung {a} fehlt auf dem Docker-Host", anzahl=1)
fall("Eintrag null", s_stand(s_eintraege({a: None})), befund=f"Sicherung {a} fehlt auf dem Docker-Host", anzahl=1)
fall("Eintrag als Liste", s_stand(s_eintraege({a: [S_SUMME, S_SUMME]})),
befund=f"Sicherung {a} fehlt auf dem Docker-Host", anzahl=1)
# summe
for titel, eintrag in (
("summe null", {"summe": None, "bestaetigt": S_SUMME}),
("summe fehlt", {"bestaetigt": S_SUMME}),
("summe leer", {"summe": "", "bestaetigt": S_SUMME}),
("summe in Grossbuchstaben", {"summe": S_SUMME.upper(), "bestaetigt": S_SUMME.upper()}),
("summe mit 63 Zeichen", {"summe": S_SUMME[:-1], "bestaetigt": S_SUMME[:-1]}),
("summe mit 65 Zeichen", {"summe": S_SUMME + "a", "bestaetigt": S_SUMME + "a"}),
("summe als Zahl", {"summe": 5, "bestaetigt": S_SUMME}),
):
fall(titel, s_stand(s_eintraege({a: eintrag})), befund=f"Sicherung {a}: keine gueltige Summe", anzahl=1)
# bestaetigt
for titel, eintrag, text in (
("bestaetigt null", {"summe": S_SUMME, "bestaetigt": None}, "nicht bestaetigt"),
("bestaetigt fehlt", {"summe": S_SUMME}, "nicht bestaetigt"),
("bestaetigt leer", {"summe": S_SUMME, "bestaetigt": ""}, "nicht bestaetigt"),
("bestaetigt in Grossbuchstaben", {"summe": S_SUMME, "bestaetigt": S_SUMME.upper()}, "nicht bestaetigt"),
("bestaetigt erstes Zeichen anders", {"summe": S_SUMME, "bestaetigt": "0" + S_SUMME[1:]},
"die Bestaetigung passt nicht zur Summe"),
("bestaetigt letztes Zeichen anders", {"summe": S_SUMME, "bestaetigt": S_SUMME[:-1] + "0"},
"die Bestaetigung passt nicht zur Summe"),
("Bestaetigung einer anderen Sicherung", {"summe": S_SUMME, "bestaetigt": S_ANDERE},
"die Bestaetigung passt nicht zur Summe"),
):
fall(titel, s_stand(s_eintraege({a: {**eintrag, "archiv": True}})), befund=f"Sicherung {a}: {text}", anzahl=1)
# archiv (seit der Plankritik Runde 1): Bei gueltiger Summe zaehlt nur true, sonst fehlt das Archiv.
for titel, eintrag in (
("archiv false", {"summe": S_SUMME, "bestaetigt": S_SUMME, "archiv": False}),
("archiv fehlt", {"summe": S_SUMME, "bestaetigt": S_SUMME}),
("archiv null", {"summe": S_SUMME, "bestaetigt": S_SUMME, "archiv": None}),
("archiv als Text", {"summe": S_SUMME, "bestaetigt": S_SUMME, "archiv": "true"}),
("archiv false, nicht bestaetigt", {"summe": S_SUMME, "bestaetigt": None, "archiv": False}),
):
fall(titel, s_stand(s_eintraege({a: eintrag})), befund=f"Sicherung {a}: Archiv fehlt auf dem Docker-Host",
anzahl=1)
# Instanz nach --teil
fall("Teil ibkr, nur Alpaca bestaetigt", s_stand(s_eintraege({i: offen})), teil="ibkr",
befund=f"Sicherung {i}: nicht bestaetigt", anzahl=1, nicht=("alpaca",))
fall("Teil alpaca, nur IBKR bestaetigt", s_stand(s_eintraege({a: offen})),
befund=f"Sicherung {a}: nicht bestaetigt", anzahl=1, nicht=("ibkr",))
fall("Teil ibkr, Eintrag fehlt", s_stand(s_eintraege(ohne=(i,))), teil="ibkr",
befund=f"Sicherung {i} fehlt auf dem Docker-Host", anzahl=1)
# Reihenfolge: derselbe Schaden am ersten und am letzten Eintrag
andere = {name: wert for name, wert in s_eintraege().items() if name != a}
fall("Schaden am ersten Eintrag", s_stand({a: offen, **andere}), befund=f"Sicherung {a}: nicht bestaetigt", anzahl=1)
fall("Schaden am letzten Eintrag", s_stand({**andere, a: offen}), befund=f"Sicherung {a}: nicht bestaetigt", anzahl=1)
# Kalender: der juengste Handelstag vor TAG, nicht der Vortag und nicht der letzte Listeneintrag
fall("Kalender unsortiert", s_stand(s_eintraege({a: offen})), kalender=list(reversed(S_KALENDER)),
befund=f"Sicherung {a}: nicht bestaetigt", anzahl=1)
feiertag = [S_KALENDER[0]] + KALENDER_TAG
fall("Feiertag am Vortag, der Tag davor bestaetigt", s_stand(s_eintraege({a: offen})), kalender=feiertag,
befund=None, anzahl=0, fakt=lambda f, e: f.get("sicherung", {}).get("name") == f"{S_DAVOR}-alpaca")
fall("Feiertag am Vortag, der Tag davor offen", s_stand(s_eintraege({f"{S_DAVOR}-alpaca": offen})),
kalender=feiertag, befund=f"Sicherung {S_DAVOR}-alpaca: nicht bestaetigt", anzahl=1)
fall("Kalender ohne Tag davor", s_stand(), kalender=KALENDER_TAG, befund="Sicherung: kein Handelstag in den", anzahl=1)
fall("Kalender als Objekt", s_stand(), kalender={}, befund="Sicherung nicht geprueft (ValueError", anzahl=2,
auch=("Kalender in unerwarteter Form",))
fall("Kalender Eintrag ohne date, erste Position", s_stand(), kalender=[{"open": "09:30"}] + S_KALENDER,
befund="Sicherung nicht geprueft (ValueError", anzahl=2)
fall("Kalender Eintrag ohne date, letzte Position", s_stand(), kalender=S_KALENDER + [{"date": 5}],
befund="Sicherung nicht geprueft (ValueError", anzahl=2)
fall("Kalender mit Datum in anderer Form", s_stand(), kalender=[{"date": "22.09.2026"}] + S_KALENDER,
befund="Sicherung nicht geprueft (ValueError", anzahl=1)
# Fenster (seit der Plankritik Runde 1): SICHERUNG_FENSTER_TAGE Kalendertage vor TAG, der Rand gehoert
# dazu, TAG selbst nie. Das Werkzeug prueft keinen Wochentag; Rand und Tag davor sind Wochenendtage.
rand = "2026-09-13"
fall("Kalender voriger Handelstag am Rand des Fensters",
s_stand(s_eintraege({f"{rand}-alpaca": {"summe": S_SUMME, "bestaetigt": S_SUMME, "archiv": True}}),
erster_tag="2026-09-01"),
kalender=[{"date": rand, "open": "09:30", "close": "16:00"}] + KALENDER_TAG, befund=None, anzahl=0,
fakt=lambda f, e: f.get("sicherung", {}).get("name") == f"{rand}-alpaca")
fall("Kalender voriger Handelstag ausserhalb des Fensters", s_stand(),
kalender=[{"date": "2026-09-12", "open": "09:30", "close": "16:00"}] + KALENDER_TAG,
befund=f"Sicherung: kein Handelstag in den 10 Tagen vor {TAG} im Kalender", anzahl=1)
fall("Kalender Tag selbst nie der vorige", s_stand(s_eintraege({f"{TAG}-alpaca": offen})),
kalender=KALENDER_TAG + [S_KALENDER[1]], befund=None, anzahl=0,
fakt=lambda f, e: f.get("sicherung", {}).get("name") == a)
# Zusaetzlich: unbekannte Schluessel und fremde Namen stoeren nicht
fall("unbekannte Schluessel und fremde Namen",
s_stand(s_eintraege({"notiz": {"summe": None}, f"{S_VORHER}-paper": None}), neu=1), befund=None, anzahl=0)
# Bedienfehler: Ein falscher Tag bricht den Teil ab, und die Sicherung meldet sich trotzdem.
fall("Tag falsch", s_stand(), tag="2026-13-40", befund="abgebrochen", exit_code=2, anzahl=2,
auch=("Sicherung nicht geprueft (ValueError",))
# Meldeweg: Ein Befund der Sicherung allein loest die Meldung aus (lokaler Probeserver, kein Netz).
with tempfile.TemporaryDirectory() as tmp_server:
mitschrift = Path(tmp_server) / "mitschrift.jsonl"
port = 18767
server = subprocess.Popen([sys.executable, str(HIER / "ntfy_probe_server.py"), str(port), str(mitschrift)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
import socket
import time
for _ in range(50):
try:
socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
break
except OSError:
time.sleep(0.1)
with tempfile.TemporaryDirectory() as tmp:
wurzel = Path(tmp)
projekt = baue_projekt(wurzel)
(wurzel / "kalender.json").write_text(json.dumps(S_KALENDER), encoding="utf-8")
(wurzel / "sicherung.json").write_text(json.dumps(s_stand(s_eintraege({a: offen}))), encoding="utf-8")
text = (ECHTES_PROJEKT / "config" / "paper.toml").read_text(encoding="utf-8")
anker = 'ntfy_url = "https://ntfy.sh"'
if text.count(anker) != 1:
raise RuntimeError("Anker ntfy_url fehlt in config/paper.toml")
(projekt / "config" / "paper.toml").write_text(
text.replace(anker, f'ntfy_url = "http://127.0.0.1:{port}"\nklartext_lokal = true'), encoding="utf-8")
(wurzel / "secrets.toml").write_text('[ntfy]\naktiv = true\nthema = "abendkontrolle-angriff"\n',
encoding="utf-8")
ergebnis = fahre_sicherung(wurzel, projekt, "alpaca", meldung=True,
env={"TRADING_GEHEIMNISSE": str(wurzel / "secrets.toml")})
zeilen = ([json.loads(z) for z in mitschrift.read_text(encoding="utf-8").split("\n") if z.strip()]
if mitschrift.exists() else [])
titel = [str(z.get("title") or "") for z in zeilen]
befunde = ergebnis.get("befunde", [])
gesendet = (ergebnis.get("fakten") or {}).get("meldung")
ok = (gesendet == "gesendet" and len(zeilen) == 1 and f"Abendkontrolle {TAG}: 1 Befund(e)" in titel[0]
and len(befunde) == 1 and befunde[0].startswith(f"Sicherung {a}: nicht bestaetigt"))
zeile = (f"{'OK ' if ok else 'FEHL'} Sicherung Befund geht in die Meldung: "
f"befunde={befunde[:2]} titel={titel} meldung={gesendet}")
print(zeile)
if not ok:
abweichungen.append(zeile)
finally:
server.terminate()
server.wait(timeout=10)
if __name__ == "__main__": if __name__ == "__main__":
if sys.platform == "win32": if sys.platform == "win32":
windows_faelle() windows_faelle()
ibkr_windows_faelle() ibkr_windows_faelle()
elif "--nur-sicherung" in sys.argv[1:]:
sicherung_faelle()
else: else:
wsl_faelle() wsl_faelle()
ibkr_wsl_faelle() ibkr_wsl_faelle()
sicherung_faelle()
print(f"ERGEBNIS: {len(abweichungen)} Abweichung(en)") print(f"ERGEBNIS: {len(abweichungen)} Abweichung(en)")
sys.exit(1 if abweichungen else 0) sys.exit(1 if abweichungen else 0)
File diff suppressed because it is too large. Load diff
@@ -41,6 +41,11 @@ if (-not $QuelleIbkr) { $QuelleIbkr = $Quelle }
$Aufruf = New-ScheduledTaskAction -Execute $Py -Argument ('"' + $Skript + '" --quelle ' + $Quelle + ' --quelle-ibkr ' + $QuelleIbkr) -WorkingDirectory "D:\Claude" $Aufruf = New-ScheduledTaskAction -Execute $Py -Argument ('"' + $Skript + '" --quelle ' + $Quelle + ' --quelle-ibkr ' + $QuelleIbkr) -WorkingDirectory "D:\Claude"
if ($Quelle -eq "dockerhost" -or $QuelleIbkr -eq "dockerhost") { $Uhrzeit = "23:15" } else { $Uhrzeit = "22:45" } if ($Quelle -eq "dockerhost" -or $QuelleIbkr -eq "dockerhost") { $Uhrzeit = "23:15" } else { $Uhrzeit = "22:45" }
$Abends = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday, Tuesday, Wednesday, Thursday, Friday -At $Uhrzeit $Abends = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday, Tuesday, Wednesday, Thursday, Friday -At $Uhrzeit
# Seit Paket E9 (S10): -At legt den Startzeitpunkt mit UTC-Versatz an (gemessen 2026-10-11:
# ...T21:15:00Z). Eine Aufgabe mit Versatz startet im Winter eine Stunde frueher nach Ortszeit, vor
# der Abendkontrolle des Hosts. Ohne Versatz gesetzt, speichert die Aufgabenplanung den Wert so und
# startet nach Ortszeit.
$Abends.StartBoundary = (Get-Date -Format 'yyyy-MM-dd') + 'T' + $Uhrzeit + ':00'
$Anmeldung = New-ScheduledTaskTrigger -AtLogOn -User $Benutzer $Anmeldung = New-ScheduledTaskTrigger -AtLogOn -User $Benutzer
$Anmeldung.Delay = "PT5M" $Anmeldung.Delay = "PT5M"
$Wer = New-ScheduledTaskPrincipal -UserId $Benutzer -LogonType Interactive -RunLevel Limited $Wer = New-ScheduledTaskPrincipal -UserId $Benutzer -LogonType Interactive -RunLevel Limited
@@ -4,8 +4,11 @@ Koordinator-Werkzeug, nicht fuers Repo. Jeder Mutant laeuft auf einer Wegwerfkop
In WSL (Venv) die Mutanten von abendkontrolle_wsl.py, unter Windows die von abendkontrolle.py und In WSL (Venv) die Mutanten von abendkontrolle_wsl.py, unter Windows die von abendkontrolle.py und
rueckweg_aapl.py. Aufruf: python abendkontrolle_mutanten.py Exit 0, wenn keiner ueberlebt. rueckweg_aapl.py. Aufruf: python abendkontrolle_mutanten.py Exit 0, wenn keiner ueberlebt.
Ein Mutant, der das Verhalten nicht aendert, ist keine Luecke (AI/Regelwerk/pruefwerkzeuge.md, Abschnitt 8). Ein Mutant, der das Verhalten nicht aendert, ist keine Luecke (AI/Regelwerk/pruefwerkzeuge.md, Abschnitt 8).
Seit Paket E9: --nur TEXT faehrt nur die Mutanten mit TEXT im Namen (etwa --nur Sicherung in WSL); ein
fuenftes Element im Tupel geht als Argument an den Treiber.
""" """
import argparse
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -64,6 +67,78 @@ WSL_MUTANTEN = [
("IBKR Muster aus", "abendkontrolle_wsl.py", " if muster in text:", " if False:"), ("IBKR Muster aus", "abendkontrolle_wsl.py", " if muster in text:", " if False:"),
("IBKR Titel ohne Praefix", "abendkontrolle_wsl.py", 'IBKR_TITEL = "IBKR "', 'IBKR_TITEL = ""'), ("IBKR Titel ohne Praefix", "abendkontrolle_wsl.py", 'IBKR_TITEL = "IBKR "', 'IBKR_TITEL = ""'),
("IBKR Meldeweiche aus", "abendkontrolle_wsl.py", "melde(IBKR_TITEL + titel if ibkr else titel,", "melde(titel,"), ("IBKR Meldeweiche aus", "abendkontrolle_wsl.py", "melde(IBKR_TITEL + titel if ibkr else titel,", "melde(titel,"),
# Seit Paket E9 (Task 726): --sicherung. Der Treiber faehrt dafuer nur seine Faelle zur Sicherung.
("Sicherung letzter Listeneintrag statt juengster Tag", "abendkontrolle_wsl.py",
" return max(tage) if tage else None", " return tage[-1] if tage else None", "--nur-sicherung"),
("Sicherung Vortag statt Kalender", "abendkontrolle_wsl.py",
" vorher = juengster_handelstag(lies_kalender_spanne(start, ende, argumente.kalender, Path(argumente.projekt)))",
" vorher = ende", "--nur-sicherung"),
("Sicherung Kalenderform egal", "abendkontrolle_wsl.py",
' raise ValueError("Kalender mit einem Eintrag ohne date JJJJ-MM-TT")', " continue",
"--nur-sicherung"),
("Sicherung Fehler des Hosts ueberlesen", "abendkontrolle_wsl.py", ' if "fehler" in stand:', " if False:",
"--nur-sicherung"),
("Sicherung Stand ohne Typpruefung", "abendkontrolle_wsl.py", " if not isinstance(stand, dict):", " if False:",
"--nur-sicherung"),
("Sicherung eintraege ohne Typpruefung", "abendkontrolle_wsl.py",
' if "erster_tag" not in stand or not isinstance(stand.get("eintraege"), dict):',
' if "erster_tag" not in stand:', "--nur-sicherung"),
("Sicherung erster_tag ohne Formpruefung", "abendkontrolle_wsl.py",
" if not isinstance(erster, str) or not DATUM.fullmatch(erster):", " if False:", "--nur-sicherung"),
("Sicherung ohne erster_tag nur Hinweis", "abendkontrolle_wsl.py",
' k.befund("Sicherung nicht geprueft: auf dem Docker-Host fehlt erster-tag der Sicherung")',
' k.hinweis("Sicherung nicht geprueft: auf dem Docker-Host fehlt erster-tag der Sicherung")', "--nur-sicherung"),
("Sicherung vor erster_tag geprueft", "abendkontrolle_wsl.py", " if vorher < erster:", " if False:",
"--nur-sicherung"),
("Sicherung erster_tag selbst ausgenommen", "abendkontrolle_wsl.py", " if vorher < erster:",
" if vorher <= erster:", "--nur-sicherung"),
("Sicherung kein Handelstag still", "abendkontrolle_wsl.py",
' k.befund(f"Sicherung: kein Handelstag in den {SICHERUNG_FENSTER_TAGE} Tagen vor {tag} im Kalender")',
" pass", "--nur-sicherung"),
("Sicherung immer alpaca", "abendkontrolle_wsl.py",
' instanz = "ibkr" if argumente.teil == "ibkr" else "alpaca"', ' instanz = "alpaca"', "--nur-sicherung"),
("Sicherung fehlender Eintrag still", "abendkontrolle_wsl.py",
' k.befund(f"Sicherung {name} fehlt auf dem Docker-Host")', " pass", "--nur-sicherung"),
("Sicherung Summe ungeprueft", "abendkontrolle_wsl.py",
" if not isinstance(summe, str) or not SUMME.fullmatch(summe):", " if summe is None:", "--nur-sicherung"),
("Sicherung Bestaetigung ungeprueft", "abendkontrolle_wsl.py",
" elif not isinstance(bestaetigt, str) or not SUMME.fullmatch(bestaetigt):", " elif False:",
"--nur-sicherung"),
("Sicherung Vergleich aus", "abendkontrolle_wsl.py", " elif bestaetigt != summe:", " elif False:",
"--nur-sicherung"),
("Sicherung Schalter nicht verdrahtet", "abendkontrolle_wsl.py", " if argumente.sicherung:", " if False:",
"--nur-sicherung"),
# Seit der Plankritik Runde 1 (Task 726): Fenster, erster_tag, Archiv und die zwei Faelle ohne Mutanten.
("Sicherung Fenster 1 Tag", "abendkontrolle_wsl.py", "SICHERUNG_FENSTER_TAGE = 10", "SICHERUNG_FENSTER_TAGE = 1",
"--nur-sicherung"),
("Sicherung Fenster einen Tag kuerzer", "abendkontrolle_wsl.py",
" start = (geprueft - timedelta(days=SICHERUNG_FENSTER_TAGE)).isoformat()",
" start = (geprueft - timedelta(days=SICHERUNG_FENSTER_TAGE - 1)).isoformat()", "--nur-sicherung"),
("Sicherung Ende am Tag selbst", "abendkontrolle_wsl.py",
" ende = (geprueft - timedelta(days=1)).isoformat()", " ende = geprueft.isoformat()", "--nur-sicherung"),
("Sicherung Spanne der Datei aus", "abendkontrolle_wsl.py",
' return in_spanne(json.loads(Path(kalender_datei).read_text(encoding="utf-8")), start, ende)',
' return json.loads(Path(kalender_datei).read_text(encoding="utf-8"))', "--nur-sicherung"),
("Sicherung Spanne ohne Anfang", "abendkontrolle_wsl.py", ' or start <= eintrag["date"] <= ende',
' or eintrag["date"] <= ende', "--nur-sicherung"),
("Sicherung Spanne ohne Typpruefung", "abendkontrolle_wsl.py",
" if not isinstance(liste, list):\n return liste", " if False:\n return liste",
"--nur-sicherung"),
("Sicherung Kalender ohne Typpruefung", "abendkontrolle_wsl.py",
' if not isinstance(liste, list):\n raise ValueError("Kalender ist keine Liste")',
' if False:\n raise ValueError("Kalender ist keine Liste")', "--nur-sicherung"),
("Sicherung erster_tag nach dem Tag still", "abendkontrolle_wsl.py", " if erster > tag:", " if False:",
"--nur-sicherung"),
("Sicherung erster_tag am Tag selbst als Befund", "abendkontrolle_wsl.py", " if erster > tag:",
" if erster >= tag:", "--nur-sicherung"),
("Sicherung Archiv ungeprueft", "abendkontrolle_wsl.py", ' elif eintrag.get("archiv") is not True:',
" elif False:", "--nur-sicherung"),
("Sicherung Archiv nur false zaehlt", "abendkontrolle_wsl.py", ' elif eintrag.get("archiv") is not True:',
' elif eintrag.get("archiv") is False:', "--nur-sicherung"),
("Sicherung ohne Schalter geprueft", "abendkontrolle_wsl.py", " if argumente.sicherung:", " if True:",
"--nur-sicherung"),
("Sicherung unbekannter Schluessel als Fehler", "abendkontrolle_wsl.py", ' if "fehler" in stand:',
' if "fehler" in stand or set(stand) - {"erster_tag", "eintraege"}:', "--nur-sicherung"),
] ]
WIN_MUTANTEN = [ WIN_MUTANTEN = [
("keine Ergebnisdatei", "abendkontrolle.py", '(ablage / f"{tag}{endung}.json").write_text(', ("keine Ergebnisdatei", "abendkontrolle.py", '(ablage / f"{tag}{endung}.json").write_text(',
@@ -109,9 +184,19 @@ TREIBER = {
"rueckweg_aapl.py": "rueckweg_aapl_angriffe.py", "rueckweg_aapl.py": "rueckweg_aapl_angriffe.py",
} }
zerleger = argparse.ArgumentParser(description=__doc__.splitlines()[0])
# Seit Paket E9: nur die Mutanten, deren Name den Text enthaelt.
zerleger.add_argument("--nur", help="nur Mutanten, deren Name diesen Text enthaelt")
argumente = zerleger.parse_args()
mutanten = WIN_MUTANTEN if sys.platform == "win32" else WSL_MUTANTEN mutanten = WIN_MUTANTEN if sys.platform == "win32" else WSL_MUTANTEN
if argumente.nur is not None:
mutanten = [mutant for mutant in mutanten if argumente.nur in mutant[0]]
if not mutanten:
print(f"KEIN MUTANT mit {argumente.nur!r} im Namen")
sys.exit(2)
ueberlebt = [] ueberlebt = []
for name, datei, alt, neu in mutanten: # Ein fuenftes Element im Tupel geht als Argument an den Treiber (seit Paket E9).
for name, datei, alt, neu, *treiber_argumente in mutanten:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
ziel = Path(tmp) / "pruefung" ziel = Path(tmp) / "pruefung"
shutil.copytree(HIER, ziel, ignore=shutil.ignore_patterns("__pycache__")) shutil.copytree(HIER, ziel, ignore=shutil.ignore_patterns("__pycache__"))
@@ -122,7 +207,7 @@ for name, datei, alt, neu in mutanten:
ueberlebt.append(name) ueberlebt.append(name)
continue continue
pfad.write_text(text.replace(alt, neu), encoding="utf-8", newline="\n") pfad.write_text(text.replace(alt, neu), encoding="utf-8", newline="\n")
lauf = subprocess.run([sys.executable, str(ziel / TREIBER[datei])], capture_output=True, text=True, lauf = subprocess.run([sys.executable, str(ziel / TREIBER[datei]), *treiber_argumente], capture_output=True, text=True,
timeout=1800, encoding="utf-8", errors="replace", cwd=tmp) timeout=1800, encoding="utf-8", errors="replace", cwd=tmp)
faelle = [z for z in lauf.stdout.splitlines() if z.startswith("FEHL")] faelle = [z for z in lauf.stdout.splitlines() if z.startswith("FEHL")]
rot = lauf.returncode != 0 rot = lauf.returncode != 0
@@ -81,8 +81,36 @@ Seit Paket IB2 prueft --teil ibkr statt des Alpaca-Runners die IBKR-Instanz in i
Nicht gesehen im Teil ibkr: ein Tagesbericht (die Instanz hat keine Tagesauswertung), Nicht gesehen im Teil ibkr: ein Tagesbericht (die Instanz hat keine Tagesauswertung),
handelstag_auswertung.py und der Dienst ibkr-gateway.service. handelstag_auswertung.py und der Dienst ibkr-gateway.service.
Seit Paket E9 prueft --sicherung <datei> fuer beide Teile die Sicherung des vorigen Handelstags auf
dem Docker-Host. Die Datei schreibt abendkontrolle-host.sh vor jedem Lauf (absoluter Pfad, im
Container /abend/sicherung.json); die Ablage selbst sieht das Werkzeug nicht. Die Pruefung laeuft
nach dem Teil in einem eigenen Schritt, auch an einem Tag ohne Handel und nach einem Abbruch.
E1. Ist der Stand lesbar? Projektvorgabe (Plan E9, Task 726): Datei fehlt, kein JSON, kein
Objekt, ohne erster_tag oder eintraege -> Befund. Ein Schluessel
fehler (Ablage fehlt, nicht lesbar, erster-tag kaputt, seit Paket NT
auch eine unlesbare Summendatei oder Bestaetigung) -> Befund.
E2. Ab wann wird geprueft? Projektvorgabe (S6, S8): erster_tag aus erster-tag der Ablage;
null, kein Datum oder ein Tag nach --tag -> Befund, sonst
schwiege die Pruefung auf Dauer; nur ein voriger Handelstag vor
erster_tag -> nicht geprueft, still.
E3. Welcher Tag? Quelle: derselbe Kalender wie Frage 1, GET /v2/calendar fuer die
SICHERUNG_FENSTER_TAGE Kalendertage vor --tag, bis zum Vortag; der
juengste Eintrag darin, unabhaengig von der Reihenfolge der Liste.
Die Spanne setzt das Werkzeug selbst durch, fuer die Antwort wie
fuer eine Kalenderdatei. Form wie Frage 1, dazu date als
JJJJ-MM-TT, sonst Befund; keiner -> Befund.
E4. Liegt die Sicherung? Projektvorgabe (Schnittstelle E9): eintraege["<tag>-<instanz>"],
instanz nach --teil; fehlt der Eintrag -> Befund; summe nicht 64
Hexzeichen in Kleinbuchstaben -> Befund; archiv nicht true (die
Datei <name>.tar fehlt auf dem Host) -> Befund.
E5. Ist sie nach D: geholt? Projektvorgabe (S4, S5): bestaetigt hat die Form von summe und ist
ihr gleich, sonst Befund. Die Windows-Seite bestaetigt erst nach
geprueftem Archiv, und abendkontrolle-lesen.sh nimmt nur die Summe
des Hosts an.
Nicht gesehen: ob die Kopie auf D: noch liegt und ob sie sich zuruecksichern laesst. Ein Werktag ohne
Handel hat eine Sicherung, die niemand prueft.
Ein Fangnetz, kein Nachweis ueber den Handelstag. Ein Fangnetz, kein Nachweis ueber den Handelstag.
Aufruf (WSL, Venv): python abendkontrolle_wsl.py --tag JJJJ-MM-TT [--ohne-meldung] [--teil ibkr] Aufruf (WSL, Venv): python abendkontrolle_wsl.py --tag JJJJ-MM-TT [--ohne-meldung] [--teil ibkr] [--sicherung <datei>]
Nur eine Meldung schicken: python abendkontrolle_wsl.py --meldung "<titel>" "<text>" Nur eine Meldung schicken: python abendkontrolle_wsl.py --meldung "<titel>" "<text>"
Fuer Angriffsfaelle ohne Netz und ohne systemd: --projekt, --kalender, --journal, --dienste, Fuer Angriffsfaelle ohne Netz und ohne systemd: --projekt, --kalender, --journal, --dienste,
--ohne-broker, --ohne-kerzen; im Teil ibkr dazu --ibkr-ordner, --ibkr-konto (JSON mit exit und --ohne-broker, --ohne-kerzen; im Teil ibkr dazu --ibkr-ordner, --ibkr-konto (JSON mit exit und
@@ -585,6 +613,142 @@ def kontrolliere_ibkr(argumente: argparse.Namespace, k: Kontrolle) -> None:
pruefe_konto_ibkr(ergebnis[0], ergebnis[1], k) pruefe_konto_ibkr(ergebnis[0], ergebnis[1], k)
# --- Sicherung des vorigen Handelstags (seit Paket E9) ---------------------------------------------
# Wie weit der Kalender zurueck gefragt wird. Projektvorgabe (Plan E9, Task 726), nicht am Kalender
# gemessen: Zehn Kalendertage decken ein langes Wochenende mit Feiertag und eine kurze
# ausserplanmaessige Schliessung.
SICHERUNG_FENSTER_TAGE = 10
# Summe und Bestaetigung auf dem Host (Schnittstelle Paket E9) und ein Datum, nur ASCII-Ziffern.
SUMME = re.compile(r"[0-9a-f]{64}")
DATUM = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")
def in_spanne(liste, start: str, ende: str):
"""Aus einer Liste des Kalenders nur die Eintraege, deren date von start bis ende reicht.
Es faellt nur ein Eintrag mit einem date JJJJ-MM-TT ausserhalb der Spanne weg; jeder andere
bleibt, und eine Antwort, die keine Liste ist, kommt unveraendert zurueck. Die Form prueft
juengster_handelstag.
"""
if not isinstance(liste, list):
return liste
return [
eintrag
for eintrag in liste
if not isinstance(eintrag, dict)
or not isinstance(eintrag.get("date"), str)
or not DATUM.fullmatch(eintrag["date"])
or start <= eintrag["date"] <= ende
]
def lies_kalender_spanne(start: str, ende: str, kalender_datei: str | None, projekt: Path):
"""Die Liste aus /v2/calendar oder aus einer Datei (Angriffsfaelle), je nur von start bis ende.
Die Spanne setzt das Werkzeug fuer beide Quellen selbst durch (Plan E9, Plankritik Runde 1): So
pinnen die Angriffsfaelle mit Kalenderdatei die Grenzen, mit denen es Alpaca fragt.
"""
if kalender_datei is not None:
return in_spanne(json.loads(Path(kalender_datei).read_text(encoding="utf-8")), start, ende)
import requests # erst hier: Die Angriffsfaelle laufen ohne Netz
sys.path.insert(0, str(projekt))
sys.path.insert(0, str(AUSWERTUNG.parent))
from handelstag_auswertung import kopfzeilen # liest die Schluessel, gibt sie nie aus
kopf = kopfzeilen()
if kopf is None:
raise RuntimeError("Schluessel fuer Alpaca nicht lesbar")
antwort = requests.get(
f"{HANDEL_URL}/v2/calendar", headers=kopf, params={"start": start, "end": ende}, timeout=30
)
antwort.raise_for_status()
return in_spanne(antwort.json(), start, ende)
def juengster_handelstag(liste) -> str | None:
"""Der juengste Handelstag in der Liste, None, wenn keiner darin steht.
Die Liste reicht schon nur bis zum Tag vor dem geprueften (lies_kalender_spanne). Die Grenze steht
allein dort, damit ein Angriffsfall sie pinnen kann. Die Reihenfolge der Liste zaehlt nicht. Hat die
Liste nicht die gemessene Form (Frage 1) oder ein date nicht die Form JJJJ-MM-TT, ist das ein
ValueError.
"""
if not isinstance(liste, list):
raise ValueError("Kalender ist keine Liste")
tage = []
for eintrag in liste:
datum = eintrag.get("date") if isinstance(eintrag, dict) else None
if not isinstance(datum, str) or not DATUM.fullmatch(datum):
raise ValueError("Kalender mit einem Eintrag ohne date JJJJ-MM-TT")
tage.append(datum)
return max(tage) if tage else None
def pruefe_sicherung(argumente: argparse.Namespace, k: Kontrolle) -> None:
"""Die Sicherung des vorigen Handelstags fuer den geprueften Teil (Paket E9, S6).
Liest nur die Datei aus --sicherung, nie die Ablage. Jede Abweichung ist ein Befund, auch ein
fehlender erster Sicherungstag, einer nach dem geprueften Tag und ein fehlendes Archiv. Still
bleibt nur ein voriger Handelstag vor dem ersten Sicherungstag.
"""
geprueft = date.fromisoformat(argumente.tag) # ein falsches Datum ist ein Bedienfehler
tag = geprueft.isoformat()
instanz = "ibkr" if argumente.teil == "ibkr" else "alpaca"
stand = json.loads(Path(argumente.sicherung).read_text(encoding="utf-8"))
if not isinstance(stand, dict):
k.befund("Sicherung: Stand ist kein Objekt")
return
if "fehler" in stand:
k.befund(f"Sicherung nicht geprueft: {str(stand['fehler'])[:160]}")
return
if "erster_tag" not in stand or not isinstance(stand.get("eintraege"), dict):
k.befund("Sicherung: Stand in unerwarteter Form (erster_tag oder eintraege fehlt)")
return
erster = stand["erster_tag"]
if erster is None:
# Seit der Plankritik Runde 1 ein Befund: Ohne erster-tag schwiege die Pruefung auf Dauer.
k.befund("Sicherung nicht geprueft: auf dem Docker-Host fehlt erster-tag der Sicherung")
return
if not isinstance(erster, str) or not DATUM.fullmatch(erster):
k.befund("Sicherung: erster_tag ist kein Datum JJJJ-MM-TT")
return
# Ein erster Sicherungstag nach dem geprueften Tag (etwa ein Tippfehler im Jahr) schaltete die
# Pruefung sonst still ab, bis der Tag kommt. DATUM hat die Form gesichert, also vergleicht der
# Text wie das Datum.
if erster > tag:
k.befund(f"Sicherung nicht geprueft: erster_tag {erster} liegt nach dem geprueften Tag {tag}")
return
start = (geprueft - timedelta(days=SICHERUNG_FENSTER_TAGE)).isoformat()
ende = (geprueft - timedelta(days=1)).isoformat()
vorher = juengster_handelstag(lies_kalender_spanne(start, ende, argumente.kalender, Path(argumente.projekt)))
if vorher is None:
k.befund(f"Sicherung: kein Handelstag in den {SICHERUNG_FENSTER_TAGE} Tagen vor {tag} im Kalender")
return
name = f"{vorher}-{instanz}"
k.fakten["sicherung"] = {"name": name, "erster_tag": erster, "geprueft": False}
# Still bleibt nur das: ein voriger Handelstag vor dem ersten Sicherungstag (die ersten Tage nach
# dem Einspielen).
if vorher < erster:
return
k.fakten["sicherung"]["geprueft"] = True
eintrag = stand["eintraege"].get(name)
if not isinstance(eintrag, dict):
k.befund(f"Sicherung {name} fehlt auf dem Docker-Host")
return
summe = eintrag.get("summe")
bestaetigt = eintrag.get("bestaetigt")
if not isinstance(summe, str) or not SUMME.fullmatch(summe):
k.befund(f"Sicherung {name}: keine gueltige Summe auf dem Docker-Host")
elif eintrag.get("archiv") is not True:
k.befund(f"Sicherung {name}: Archiv fehlt auf dem Docker-Host")
elif not isinstance(bestaetigt, str) or not SUMME.fullmatch(bestaetigt):
k.befund(f"Sicherung {name}: nicht bestaetigt, also nicht nach D: geholt")
elif bestaetigt != summe:
k.befund(f"Sicherung {name}: die Bestaetigung passt nicht zur Summe auf dem Docker-Host")
# --- Meldung --------------------------------------------------------------------------------------- # --- Meldung ---------------------------------------------------------------------------------------
def melde(titel: str, text: str, projekt: Path) -> str: def melde(titel: str, text: str, projekt: Path) -> str:
@@ -678,6 +842,8 @@ def parse_args(argv=None) -> argparse.Namespace:
# Nur fuer die Angriffsfaelle: Exit und Ausgabe von ibkr_pruefen.py als JSON-Datei, und die Uhr. # Nur fuer die Angriffsfaelle: Exit und Ausgabe von ibkr_pruefen.py als JSON-Datei, und die Uhr.
zerleger.add_argument("--ibkr-konto") zerleger.add_argument("--ibkr-konto")
zerleger.add_argument("--jetzt", help="ISO-Zeitpunkt mit Versatz statt der Uhr") zerleger.add_argument("--jetzt", help="ISO-Zeitpunkt mit Versatz statt der Uhr")
# Seit Paket E9 (S6): der Stand der Sicherungsablage des Hosts als JSON-Datei (abendkontrolle-host.sh).
zerleger.add_argument("--sicherung")
argumente = zerleger.parse_args(argv) argumente = zerleger.parse_args(argv)
if bool(argumente.tag) == bool(argumente.meldung): if bool(argumente.tag) == bool(argumente.meldung):
zerleger.error("genau eines von --tag und --meldung") zerleger.error("genau eines von --tag und --meldung")
@@ -702,6 +868,9 @@ def main(argv=None) -> int:
except Exception as ausnahme: except Exception as ausnahme:
k.befund(f"Abendkontrolle abgebrochen: {type(ausnahme).__name__}: {ausnahme}") k.befund(f"Abendkontrolle abgebrochen: {type(ausnahme).__name__}: {ausnahme}")
code = 2 code = 2
# Seit Paket E9 (S6): die Sicherung des vorigen Handelstags, fuer sich und vor der Meldung.
if argumente.sicherung:
k.schritt("Sicherung", pruefe_sicherung, argumente, k)
if k.befunde and not argumente.ohne_meldung: if k.befunde and not argumente.ohne_meldung:
text = "\n".join(f"- {befund}" for befund in k.befunde[:12]) text = "\n".join(f"- {befund}" for befund in k.befunde[:12])
titel = f"Abendkontrolle {argumente.tag}: {len(k.befunde)} Befund(e)" titel = f"Abendkontrolle {argumente.tag}: {len(k.befunde)} Befund(e)"