"""Abendkontrolle eines Paper-Handelstags: liest nur, urteilt im Code, meldet nur bei Befund. Koordinator-Werkzeug, nicht fuers Repo. Laeuft in WSL im Venv des Projekts; gestartet von abendkontrolle.py (Windows-Aufgabe "Trading Plattform - Abendkontrolle", werktags 22:45 Berlin und bei jeder Anmeldung). Auftrag Pascal 2026-09-24: taegliche Abendkontrolle, dauerhaft, uebersteht Neustarts und blockiert keine Updates von Claude Code oder Desktop -- deshalb ohne Claude-Prozess. Entscheidungsliste (Frage -> Herkunft der Antwort): 1. Ist ein Handelstag? Quelle: GET /v2/calendar?start=&end=, gemessen am 2026-09-24: eine Liste von Objekten mit 'date' (JJJJ-MM-TT) und 'close' (HH:MM New York); Feiertage fehlen, das Wochenende ist leer, verkuerzte Tage tragen ein frueheres 'close'. Antwort nicht lesbar -> Befund, und die Fragen 2 bis 7 bleiben offen. 2. Lief ein scharfer Runner? Quelle: scripts/paper.py:554, Protokoll artifacts/paper/-paper.jsonl. Der Start um 09:10 New York traegt das Datum des Handelstags (gilt, solange max_wartezeit_h unter etwa 13 h bleibt; heute 8). Keines -> Befund. 3. Wie endete jeder Lauf? Quelle: scripts/paper.py:517, Zeile "Ende: , Position , Orders , Killswitch , Exit " im Journal, und die Zeile 'ende' jedes Protokolls. Projektvorgabe: jeder Lauf endet mit boersenschluss und Exit 0, der letzte mit Position 0. Ausgenommen ist ein Lauf ausserhalb der Handelszeit (Ende wartezeit, im Protokoll ohne Takt; gemessen am 2026-09-20 nach WSL-Neustarts): nur ein Hinweis. Ein Lauf, der mittags mit abbruch endet, ist ein Befund, auch wenn der naechste sauber schliesst. Keine Endezeile im Journal -> Befund; "Failed with result" -> Befund. 4. Was sagt das Protokoll? Quelle: handelstag_auswertung.py (dieses Verzeichnis); jede Zeile, die mit "BEFUND" beginnt, ist ein Befund; Exit ungleich 0 ebenfalls. 5. Ist das Konto glatt? Quelle: dieselbe Auswertung mit --broker, Zeile "offene positionen beim broker jetzt: ". n != 0 -> Befund; fehlt die Zeile -> Befund (die Frage blieb unbeantwortet). 6. Hat der Runner gehandelt? Projektvorgabe (Pascal 2026-09-24, Paket AF): an einem Handelstag steht der Killswitch in mindestens einem Takt auf aktiv. Kein solcher Takt -> Befund "Tag ohne Handel". Takte, die nach dem ersten aktiven nicht mehr aktiv sind (Watchdog, Pascals Anhalten), sind ein Hinweis: Der Runner hat dafuer schon selbst gemeldet. Ereignisse auto_fortsetzen werden berichtet, nicht bewertet. 7. Liegt der Tagesbericht? Quelle: scripts/tagesauswertung.py:58 und :192, data/paper/auswertungen/.md. Fehlt -> Befund. 8. Laufen Dashboard und Zeitgeber? Projektvorgabe (Pakete F und Z): trading-dashboard.service, trading-paper.timer, trading-auswertung.timer "active"; ein anderer Zustand wird nach DIENSTE_NOCHMAL_S einmal neu gelesen (eine eben geweckte WSL startet ihre Dienste noch). 9. Wie steht der Killswitch? Quelle: Tabelle killswitch_zustand in data/registry.db (trading/registry/registry.py:281). gesperrt -> Befund: ohne "Freigeben" handelt der naechste Tag nicht. Ein Zustand ausserhalb aktiv/angehalten/gesperrt -> Befund. Jede Frage laeuft fuer sich: Scheitert eine (Sperre im Register, Journal nicht lesbar), ist das ein Befund, und die anderen werden trotzdem beantwortet. Meldung: bei mindestens einem Befund eine Meldung mit hoher Prioritaet ueber den Melder des Projekts. Ob sie ankam, sagt der Rueckgabewert von Melder.melde (None oder der Grund, trading/paper/melder.py); er steht in fakten.meldung. Kein Alarm entsteht, wenn WSL selbst nicht laeuft -- dann steht nur die Zeile in der Tagesnotiz (benannte Luecke; der Totmann in Home Assistant, Paket Z2-7, deckt sie ab). Nicht gesehen: die Guete der Kurse (steht im Bericht), Teilausfuehrungen einzeln, alles vor dem Handelstag. Betraege gibt das Werkzeug nie aus; fremde Zeilen laufen durch maskiere(). Ein Fangnetz, kein Nachweis ueber den Handelstag. Aufruf (WSL, Venv): python abendkontrolle_wsl.py --tag JJJJ-MM-TT [--ohne-meldung] Nur eine Meldung schicken: python abendkontrolle_wsl.py --meldung "" "" Fuer Angriffsfaelle ohne Netz und ohne systemd: --projekt, --kalender, --journal, --dienste, --ohne-broker, --ohne-kerzen. Ausgabe: genau eine JSON-Zeile auf stdout. Exit 0 gelaufen (Befunde stehen in der Ausgabe), 2 abgebrochen (die bis dahin gesammelten Befunde stehen trotzdem in der Ausgabe). """ import argparse import json import os import re import sqlite3 import subprocess import sys import time from datetime import date from datetime import timedelta from pathlib import Path PROJEKT = "/mnt/d/Claude/Code Projekte/active - Trading Plattform" GEHEIMNISSE = "/home/claude/.config/trading-plattform/secrets.toml" AUSWERTUNG = Path(__file__).resolve().parent / "handelstag_auswertung.py" # Nur fuer die Angriffsfaelle: ein Ersatz, der vorgegebene Zeilen druckt, statt ans Netz zu gehen. AUSWERTUNG_ERSATZ = os.environ.get("ABENDKONTROLLE_AUSWERTUNG") HANDEL_URL = "https://paper-api.alpaca.markets" DIENSTE = ("trading-dashboard.service", "trading-paper.timer", "trading-auswertung.timer") DIENSTE_NOCHMAL_S = 30 KILLSWITCH_ZUSTAENDE = ("aktiv", "angehalten", "gesperrt") AUSWERTUNG_TIMEOUT_S = 900 ENDE = re.compile( r"Ende: (?P[^,]+), Position (?P[^,]+), Orders (?P[^,]+), " r"Killswitch (?P[^,]+), Exit (?P-?\d+)" ) BROKER_OFFEN = re.compile(r"offene positionen beim broker jetzt: (\d+)") # Maske fuer alles, was aus fremder Ausgabe in Notiz und Meldung geht, in dieser Reihenfolge: # - jede Zahl hinter einem Geldwort, # - jede Zahl vor einer Waehrung, # - jede Zahl mit mehr als zwei Nachkommastellen, # - jede Zahl mit mindestens vier Stellen vor dem Komma und Nachkommastellen. MASKEN = ( (re.compile(r"(?i)\b(kontowert|konto|startkapital|equity|cash|preis|kurs|usd|pnl)([^0-9-]{0,3})-?[0-9][0-9.,]*"), lambda m: f"{m.group(1)}{m.group(2)}[x]"), (re.compile(r"-?\d[\d.,]*\s?(?:USD|EUR|\$|€)"), lambda m: "[x]"), (re.compile(r"-?\d[\d,]*\.\d{3,}"), lambda m: "[x]"), (re.compile(r"-?\d{4,}[.,]\d{1,2}\b"), lambda m: "[x]"), ) def maskiere(text: str) -> str: for muster, ersatz in MASKEN: text = muster.sub(ersatz, text) return text class Kontrolle: def __init__(self) -> None: self.befunde: list[str] = [] self.hinweise: list[str] = [] self.fakten: dict = {} def befund(self, text: str) -> None: self.befunde.append(maskiere(text)) def hinweis(self, text: str) -> None: self.hinweise.append(maskiere(text)) def schritt(self, name: str, aufruf, *argumente): """Eine Frage fuer sich: Scheitert sie, ist das ein Befund, und der Rest laeuft weiter.""" try: return aufruf(*argumente) except Exception as ausnahme: self.befund(f"{name} nicht geprueft ({type(ausnahme).__name__}: {str(ausnahme)[:160]})") return None # --- 1 Handelstag --------------------------------------------------------------------------------- def lies_kalender(tag: str, kalender_datei: str | None, projekt: Path): """Die Liste aus /v2/calendar oder aus einer Datei (Angriffsfaelle).""" if kalender_datei is not None: return json.loads(Path(kalender_datei).read_text(encoding="utf-8")) 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": tag, "end": tag}, timeout=30 ) antwort.raise_for_status() return antwort.json() def ist_handelstag(tag: str, liste) -> bool | None: """True, False, oder None, wenn die Antwort nicht die gemessene Form hat.""" if not isinstance(liste, list): return None for eintrag in liste: if not isinstance(eintrag, dict) or not isinstance(eintrag.get("date"), str): return None return any(eintrag["date"] == tag for eintrag in liste) # --- 2 Protokolle, 3 Ende je Lauf, 6 Handel -------------------------------------------------------- def protokolle(projekt: Path, tag: str) -> list[Path]: return sorted((projekt / "artifacts" / "paper").glob(f"{tag}T*-paper.jsonl")) def lies_protokoll(pfad: Path, k: Kontrolle) -> dict: takte = aktiv = pausiert_danach = unlesbar = 0 auto = [] ende = None # Dateiiteration statt splitlines: U+2028 in einem Text zerlegte sonst eine JSON-Zeile. with pfad.open(encoding="utf-8", errors="replace") as datei: for zeile in datei: if not zeile.strip(): continue try: satz = json.loads(zeile) except json.JSONDecodeError: unlesbar += 1 continue if not isinstance(satz, dict): unlesbar += 1 continue art = satz.get("art") if art == "takt": takte += 1 if satz.get("killswitch") == "aktiv": aktiv += 1 elif aktiv: pausiert_danach += 1 elif art == "ereignis" and satz.get("was") == "auto_fortsetzen": auto.append(str(satz.get("text"))[:200]) elif art == "ende": ende = { schluessel: satz.get(schluessel) for schluessel in ("grund", "fehler", "position", "positionen", "orders", "killswitch") } if unlesbar: k.befund(f"{pfad.name}: {unlesbar} Zeile(n) nicht lesbar") return {"datei": pfad.name, "takte": takte, "takte_aktiv": aktiv, "takte_pausiert_danach": pausiert_danach, "auto_fortsetzen": auto, "ende": ende} def ausserhalb_der_handelszeit(lauf: dict) -> bool: """Ein Lauf, der ohne einen einzigen Takt mit wartezeit endete (Start nach Boersenschluss).""" return bool(lauf["ende"]) and lauf["ende"].get("grund") == "wartezeit" and lauf["takte"] == 0 def pruefe_laeufe(laeufe: list[dict], k: Kontrolle) -> None: for lauf in laeufe: ende = lauf["ende"] if ende is None: continue # die Auswertung meldet "keine Zeile 'ende'" if ausserhalb_der_handelszeit(lauf): k.hinweis(f"{lauf['datei']}: Lauf ausserhalb der Handelszeit (Ende wartezeit), nicht bewertet") continue if ende.get("grund") != "boersenschluss": k.befund(f"{lauf['datei']}: Lauf endete mit {ende.get('grund')} nach {lauf['takte']} Takten") if ende.get("fehler"): k.befund(f"{lauf['datei']}: Lauf endete mit Fehler {str(ende.get('fehler'))[:120]}") if lauf["takte_pausiert_danach"]: k.hinweis(f"{lauf['datei']}: {lauf['takte_pausiert_danach']} Takte nach dem Fortsetzen nicht aktiv") for text in lauf["auto_fortsetzen"]: k.hinweis(f"auto_fortsetzen: {text}") bewertet = [lauf for lauf in laeufe if not ausserhalb_der_handelszeit(lauf)] if laeufe and not bewertet: k.befund("kein Lauf des scharfen Runners in der Handelszeit, nur Laeufe nach Boersenschluss") elif bewertet and sum(lauf["takte_aktiv"] for lauf in bewertet) == 0: k.befund("Tag ohne Handel: in keinem Takt stand der Killswitch auf aktiv") # --- 3 Journal ------------------------------------------------------------------------------------- def lies_journal(tag: str, journal_datei: str | None) -> str: if journal_datei is not None: return Path(journal_datei).read_text(encoding="utf-8", errors="replace") morgen = (date.fromisoformat(tag) + timedelta(days=1)).isoformat() lauf = subprocess.run( ["journalctl", "-u", "trading-paper.service", "--since", f"{tag} 00:00", "--until", f"{morgen} 06:00", "-o", "cat", "--no-pager"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60, ) if lauf.returncode != 0: raise RuntimeError(f"journalctl Exit {lauf.returncode}") return lauf.stdout def pruefe_journal(text: str, k: Kontrolle) -> dict | None: alle = [{schluessel: wert.strip() for schluessel, wert in m.groupdict().items()} for m in ENDE.finditer(text)] # Ein Lauf ausserhalb der Handelszeit (Neustart nach Boersenschluss) endet mit wartezeit und Exit 3. enden = [satz for satz in alle if satz["grund"] != "wartezeit"] k.fakten["enden_im_journal"] = len(enden) if len(alle) > len(enden): k.hinweis(f"{len(alle) - len(enden)} Lauf/Laeufe ausserhalb der Handelszeit im Journal (Ende wartezeit)") if "Failed with result" in text: k.befund("Journal: trading-paper.service endete mit 'Failed with result'") if not enden: k.befund("Journal: keine Zeile 'Ende: …, Exit n' eines Handelslaufs von trading-paper.service an diesem Tag") return None # Jeder Lauf des Tages zaehlt: Ein Runner, der mittags mit einer Stoerung endete und neu gestartet # wurde, ist auch dann ein Befund, wenn der zweite sauber schliesst. for nummer, satz in enumerate(enden, start=1): if satz["exit"] != "0": k.befund(f"Runner (Lauf {nummer} von {len(enden)}) endete mit Exit {satz['exit']} (Grund {satz['grund']})") if satz["grund"] != "boersenschluss": k.befund(f"Runner (Lauf {nummer} von {len(enden)}) endete nicht mit boersenschluss, sondern mit {satz['grund']}") letzte = enden[-1] if letzte["position"] != "0": k.befund(f"Runner endete mit Position {letzte['position']}") return letzte # --- 4 und 5 Auswertung ---------------------------------------------------------------------------- def fahre_auswertung(pfad: Path, projekt: Path, broker: bool, kerzen: bool, k: Kontrolle) -> None: befehl = [sys.executable, AUSWERTUNG_ERSATZ or str(AUSWERTUNG), str(pfad)] if broker: befehl.append("--broker") if kerzen: befehl.append("--kerzen") lauf = subprocess.run( befehl, cwd=projekt, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=AUSWERTUNG_TIMEOUT_S, env={**os.environ, "PYTHONIOENCODING": "utf-8"}, ) zeilen = lauf.stdout.splitlines() + lauf.stderr.splitlines() for zeile in zeilen: if zeile.strip().startswith("BEFUND") or zeile.startswith("VORAUSSETZUNG FEHLT"): k.befund(f"{pfad.name}: {zeile.strip()}") if lauf.returncode != 0: k.befund(f"{pfad.name}: handelstag_auswertung.py endete mit Exit {lauf.returncode}") if broker: offen = [int(m.group(1)) for m in map(BROKER_OFFEN.search, zeilen) if m] if not offen: k.befund(f"{pfad.name}: keine Zeile zu offenen Positionen beim Broker -- Konto nicht geprueft") else: k.fakten["broker_offen"] = offen[-1] if offen[-1] != 0: k.befund(f"Beim Broker stehen {offen[-1]} offene Position(en)") # --- 8 Dienste und 9 Killswitch ------------------------------------------------------------------- def lies_dienste(dienste_datei: str | None) -> dict[str, str]: if dienste_datei is not None: return json.loads(Path(dienste_datei).read_text(encoding="utf-8")) def lesen(): ergebnis = {} for dienst in DIENSTE: lauf = subprocess.run(["systemctl", "is-active", dienst], capture_output=True, text=True, timeout=30) ergebnis[dienst] = lauf.stdout.strip() or f"exit {lauf.returncode}" return ergebnis zustaende = lesen() if any(wert != "active" for wert in zustaende.values()): time.sleep(DIENSTE_NOCHMAL_S) zustaende = lesen() return zustaende def pruefe_dienste(zustaende, k: Kontrolle) -> None: if not isinstance(zustaende, dict): k.befund("Dienste: Zustand nicht lesbar") return k.fakten["dienste"] = zustaende for dienst in DIENSTE: if zustaende.get(dienst) != "active": k.befund(f"{dienst} ist nicht aktiv ({zustaende.get(dienst)})") def pruefe_killswitch(projekt: Path, k: Kontrolle) -> None: register = projekt / "data" / "registry.db" if not register.is_file(): k.befund("Register data/registry.db fehlt") return with sqlite3.connect(f"file:{register}?mode=ro", uri=True, timeout=10) as verbindung: zeile = verbindung.execute( "SELECT zustand, seit, ausloeser FROM killswitch_zustand WHERE id = 1" ).fetchone() if zeile is None: k.befund("Register: kein Killswitch-Zustand") return k.fakten["killswitch"] = {"zustand": zeile[0], "seit": zeile[1], "ausloeser": zeile[2]} if zeile[0] == "gesperrt": k.befund("Killswitch steht auf gesperrt: Ohne 'Freigeben' handelt der naechste Tag nicht") elif zeile[0] not in KILLSWITCH_ZUSTAENDE: k.befund(f"Killswitch in unbekanntem Zustand {zeile[0]!r}") # --- Meldung --------------------------------------------------------------------------------------- def melde(titel: str, text: str, projekt: Path) -> str: """Eine Meldung mit hoher Prioritaet ueber den Melder des Projekts. Scheitert nie nach aussen. Melder.melde wirft nicht: Es gibt None bei Erfolg und sonst den Grund zurueck (Typ der Ausnahme oder "HTTP "). Genau das steht im Ergebnis. """ try: sys.path.insert(0, str(projekt)) os.chdir(projekt) from trading.paper.melder import baue_melder from trading.paper.meldungen import HOCH from trading.paper.meldungen import Meldung melder = baue_melder(config_pfad="config/paper.toml") if not melder.aktiv: return "nicht gesendet: Melder nicht aktiv" fehler = melder.melde(Meldung(titel=titel, text=maskiere(text), prioritaet=HOCH, tags="warning")) return "gesendet" if fehler is None else f"nicht gesendet: {fehler}" except Exception as ausnahme: # die Kontrolle haengt nicht an der Meldung return f"nicht gesendet: {type(ausnahme).__name__}" # --- Ablauf ---------------------------------------------------------------------------------------- def kontrolliere(argumente: argparse.Namespace, k: Kontrolle) -> None: projekt = Path(argumente.projekt) tag = argumente.tag date.fromisoformat(tag) # ein falsches Datum ist ein Bedienfehler und bricht ab # handelstag_auswertung.py und der Melder lesen aus dem Arbeitsverzeichnis (Projektordner). os.chdir(projekt) zustaende = k.schritt("Dienste", lies_dienste, argumente.dienste) if zustaende is not None: pruefe_dienste(zustaende, k) k.schritt("Killswitch", pruefe_killswitch, projekt, k) liste = k.schritt("Kalender, und damit Lauf, Ende und Bericht,", lies_kalender, tag, argumente.kalender, projekt) handelstag = None if liste is None else ist_handelstag(tag, liste) k.fakten["handelstag"] = handelstag if handelstag is None: if liste is not None: k.befund("Kalender in unerwarteter Form; Lauf, Ende und Bericht ungeprueft") return dateien = protokolle(projekt, tag) if not handelstag: if dateien: k.hinweis(f"kein Handelstag, aber {len(dateien)} Protokoll(e) des scharfen Runners") return if not dateien: k.befund("kein Protokoll des scharfen Runners an einem Handelstag -- der Runner lief nicht") laeufe = [lauf for lauf in (k.schritt(f"Protokoll {pfad.name}", lies_protokoll, pfad, k) for pfad in dateien) if lauf] k.fakten["laeufe"] = laeufe pruefe_laeufe(laeufe, k) text = k.schritt("Journal", lies_journal, tag, argumente.journal) if text is not None: k.fakten["ende"] = pruefe_journal(text, k) ausserhalb = {lauf["datei"] for lauf in laeufe if ausserhalb_der_handelszeit(lauf)} for pfad in dateien: if pfad.name in ausserhalb: continue k.schritt(f"Auswertung {pfad.name}", fahre_auswertung, pfad, projekt, not argumente.ohne_broker, not argumente.ohne_kerzen, k) bericht = projekt / "data" / "paper" / "auswertungen" / f"{tag}.md" k.fakten["bericht"] = bericht.is_file() if not bericht.is_file(): k.befund(f"Tagesbericht data/paper/auswertungen/{tag}.md fehlt") def parse_args(argv=None) -> argparse.Namespace: zerleger = argparse.ArgumentParser(description=__doc__.splitlines()[0], allow_abbrev=False) zerleger.add_argument("--tag") zerleger.add_argument("--meldung", nargs=2, metavar=("TITEL", "TEXT")) zerleger.add_argument("--projekt", default=PROJEKT) zerleger.add_argument("--kalender") zerleger.add_argument("--journal") zerleger.add_argument("--dienste") zerleger.add_argument("--ohne-broker", action="store_true") zerleger.add_argument("--ohne-kerzen", action="store_true") zerleger.add_argument("--ohne-meldung", action="store_true") argumente = zerleger.parse_args(argv) if bool(argumente.tag) == bool(argumente.meldung): zerleger.error("genau eines von --tag und --meldung") return argumente def main(argv=None) -> int: os.environ.setdefault("TRADING_GEHEIMNISSE", GEHEIMNISSE) argumente = parse_args(argv) if argumente.meldung: print(json.dumps({"meldung": melde(*argumente.meldung, Path(argumente.projekt))}, ensure_ascii=False)) return 0 k = Kontrolle() code = 0 try: kontrolliere(argumente, k) except Exception as ausnahme: k.befund(f"Abendkontrolle abgebrochen: {type(ausnahme).__name__}: {ausnahme}") code = 2 if k.befunde and not argumente.ohne_meldung: text = "\n".join(f"- {befund}" for befund in k.befunde[:12]) k.fakten["meldung"] = melde( f"Abendkontrolle {argumente.tag}: {len(k.befunde)} Befund(e)", text, Path(argumente.projekt) ) print(json.dumps( {"tag": argumente.tag, "befunde": k.befunde, "hinweise": k.hinweise, "fakten": k.fakten, "exit": code}, ensure_ascii=False, default=str, )) return code if __name__ == "__main__": sys.exit(main())