From 3e55bb915d605ad190d1fa7f1d08e8a66bf55329 Mon Sep 17 00:00:00 2001 From: Pascal Michel Date: Sun, 11 Oct 2026 17:09:14 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_0126RDfXiECscXipzf67uypJ --- .../abendkontrolle_angriffe.py | 258 ++++++++++++++++++ .../abendkontrolle_mutanten.py | 89 +++++- .../abendkontrolle_wsl.py | 170 +++++++++++- 3 files changed, 514 insertions(+), 3 deletions(-) diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_angriffe.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle_angriffe.py index 8e21f06..0155fbd 100644 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle_angriffe.py +++ b/.vault-tools/trading-plattform-pruefung/abendkontrolle_angriffe.py @@ -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, wo die Reihenfolge zaehlen kann (AI/Regelwerk/pruefwerkzeuge.md, Abschnitt 3). 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. """ @@ -811,12 +812,269 @@ def ibkr_windows_faelle() -> None: 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", "", 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 sys.platform == "win32": windows_faelle() ibkr_windows_faelle() + elif "--nur-sicherung" in sys.argv[1:]: + sicherung_faelle() else: wsl_faelle() ibkr_wsl_faelle() + sicherung_faelle() print(f"ERGEBNIS: {len(abweichungen)} Abweichung(en)") sys.exit(1 if abweichungen else 0) diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_mutanten.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle_mutanten.py index 67ed9b6..91d69bb 100644 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle_mutanten.py +++ b/.vault-tools/trading-plattform-pruefung/abendkontrolle_mutanten.py @@ -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 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). +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 subprocess import sys @@ -64,6 +67,78 @@ WSL_MUTANTEN = [ ("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 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 = [ ("keine Ergebnisdatei", "abendkontrolle.py", '(ablage / f"{tag}{endung}.json").write_text(', @@ -109,9 +184,19 @@ TREIBER = { "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 +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 = [] -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: ziel = Path(tmp) / "pruefung" shutil.copytree(HIER, ziel, ignore=shutil.ignore_patterns("__pycache__")) @@ -122,7 +207,7 @@ for name, datei, alt, neu in mutanten: ueberlebt.append(name) continue 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) faelle = [z for z in lauf.stdout.splitlines() if z.startswith("FEHL")] rot = lauf.returncode != 0 diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_wsl.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle_wsl.py index ccea6f4..f18daca 100644 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle_wsl.py +++ b/.vault-tools/trading-plattform-pruefung/abendkontrolle_wsl.py @@ -81,8 +81,35 @@ 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), handelstag_auswertung.py und der Dienst ibkr-gateway.service. +Seit Paket E9 prueft --sicherung 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) -> 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["-"], + instanz nach --teil; fehlt der Eintrag -> Befund; summe nicht 64 + Hexzeichen in Kleinbuchstaben -> Befund; archiv nicht true (die + Datei .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. -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 ] Nur eine Meldung schicken: python abendkontrolle_wsl.py --meldung "" "" 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 @@ -585,6 +612,142 @@ def kontrolliere_ibkr(argumente: argparse.Namespace, k: Kontrolle) -> None: 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 --------------------------------------------------------------------------------------- def melde(titel: str, text: str, projekt: Path) -> str: @@ -678,6 +841,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. zerleger.add_argument("--ibkr-konto") 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) if bool(argumente.tag) == bool(argumente.meldung): zerleger.error("genau eines von --tag und --meldung") @@ -702,6 +867,9 @@ def main(argv=None) -> int: except Exception as ausnahme: k.befund(f"Abendkontrolle abgebrochen: {type(ausnahme).__name__}: {ausnahme}") 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: text = "\n".join(f"- {befund}" for befund in k.befunde[:12]) titel = f"Abendkontrolle {argumente.tag}: {len(k.befunde)} Befund(e)"