Files
claude-regelwerk/.vault-tools/trading-plattform-pruefung/abendkontrolle.py
T
Pascal MichelandClaude Opus 5 127ee2041c Automatischer Push: 2 Datei(en) geändert
- .vault-tools/trading-plattform-pruefung/LIESMICH.md
- .vault-tools/trading-plattform-pruefung/abendkontrolle.py

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

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-11 04:00:01 +02:00

374 lines
18 KiB
Python

"""Startet die Abendkontrolle des Paper-Betriebs und schreibt ihr Ergebnis in die Tagesnotiz.
Koordinator-Werkzeug, nicht fuers Repo. Laeuft unter Windows mit pythonw.exe als geplante Aufgabe
"Trading Plattform - Abendkontrolle" (abendkontrolle_einrichten.ps1): werktags 22:45 (kommt ein Teil
vom Docker-Host, 23:15; seit Paket DH) und fuenf Minuten nach jeder Anmeldung. Auftrag Pascal
2026-09-24: dauerhaft, uebersteht Neustarts, blockiert keine
Updates -- deshalb eine Windows-Aufgabe (bleibt ueber Neustarts, holt verpasste Laeufe nach) und kein
Claude-Prozess (haelt keine Datei von Claude Code oder Desktop offen).
Ablauf: Jeder Werktag seit ERSTER_TAG, hoechstens NACHHOLEN_TAGE zurueck, ohne Ergebnisdatei in
ABLAGE wird geprueft; der heutige erst ab FRUEHESTENS (der Bericht der Tagesauswertung kommt 22:20).
Je Tag: abendkontrolle_wsl.py in WSL (liest nur, meldet bei Befund selbst ueber ntfy), dann zuerst das
Ergebnis als JSON nach ABLAGE und danach eine Zeile in Inbox/<tag>.md unter der ersten Ueberschrift,
die mit "## Agentenprotokoll" beginnt.
- Liefert der WSL-Teil kein Ergebnis oder endet er mit Exit 2 (abgebrochen), bleibt die Ergebnisdatei
aus, die Notiz bekommt einen Befund mit #needs-review, und der naechste Lauf versucht den Tag erneut.
- Scheitert nur die Notiz (etwa eine Datei, die kein UTF-8 ist), bleibt es bei der Ergebnisdatei und
einer Zeile in lauf.log: Die Kontrolle ist gelaufen und hat ihre Meldung schon geschickt; ein zweiter
Lauf schickte sie nur noch einmal.
- Ein Tag, der laenger als NACHHOLEN_TAGE scheitert, faellt aus dem Fenster (lauf.log behaelt ihn).
Seit Paket IB2 zwei Teile je Tag, nacheinander: alpaca wie oben, dann ibkr (abendkontrolle_wsl.py
--teil ibkr) mit eigener Ergebnisdatei <tag>-ibkr.json, eigener Zeile "Abendkontrolle IBKR" in
derselben Notiz und eigenem Nachholen ab IBKR_ERSTER_TAG. --teil nimmt nur einen Teil; mit
--ohne-wsl gilt ohne --teil nur alpaca (die Angriffsfaelle von vor IB2 bleiben gleich). Ein
Ergebnis, das zu einem anderen Teil gehoert, gilt als gescheitert.
Die Notiz wird ueber eine Nachbardatei und os.replace geschrieben: Ein Abbruch mitten im Schreiben
hinterlaesst die alte Datei, nicht eine halbe. Gegen eine Claude-Sitzung, die dieselbe Notiz im selben
Augenblick schreibt, schuetzt das nicht (Zeitfenster Lesen bis Ersetzen, Millisekunden) -- benannt.
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.
Aufruf von Hand: python abendkontrolle.py [--tag JJJJ-MM-TT] [--vault D:\\Claude] [--ohne-wsl <json-datei>]
[--quelle wsl|dockerhost] [--quelle-ibkr wsl|dockerhost]
"""
import argparse
import json
import os
import subprocess
import sys
import traceback
from datetime import date
from datetime import datetime
from datetime import time
from datetime import timedelta
from pathlib import Path
VAULT = Path(r"D:\Claude")
PROJEKT_TEIL = Path("Code Projekte") / "active - Trading Plattform"
ABLAGE_TEIL = PROJEKT_TEIL / "data" / "paper" / "abendkontrollen"
WSL = r"C:\Windows\System32\wsl.exe"
WSL_PY = "/home/claude/venvs/trading/bin/python"
WSL_SKRIPT = "/mnt/d/Claude/.vault-tools/trading-plattform-pruefung/abendkontrolle_wsl.py"
ERSTER_TAG = date(2026, 9, 24)
NACHHOLEN_TAGE = 7
FRUEHESTENS = time(22, 30)
WSL_TIMEOUT_S = 1200
KEIN_FENSTER = 0x08000000 # CREATE_NO_WINDOW: pythonw hat keine Konsole, wsl.exe soll keine oeffnen
UEBERSCHRIFT = "## Agentenprotokoll"
# Seit Paket DH (Trading Plattform 0.38.0): Nach dem Umzug auf den Docker-Host prueft der Host
# selbst (abendkontrolle-host.sh, werktags 16:30 New Yorker Zeit, beide Teile); diese Aufgabe
# liest nur noch sein Ergebnis ueber SSH. Der Schluessel darf dort genau einen Befehl
# (abendkontrolle-lesen.sh mit JJJJ-MM-TT oder JJJJ-MM-TT-ibkr), der Host ist ueber seinen
# Fingerabdruck festgelegt. Umgeschaltet wird je Teil: --quelle fuer alpaca, --quelle-ibkr fuer
# ibkr (ohne Angabe wie --quelle), weil die IBKR-Instanz an einem eigenen Abend umzieht.
QUELLEN = ("wsl", "dockerhost")
SSH = r"C:\Windows\System32\OpenSSH\ssh.exe"
SSH_ZIEL = "[email protected]"
SSH_PORT = "2498"
SSH_SCHLUESSEL = Path.home() / ".ssh" / "trading-dh-kontrolle"
# Ohne Leerzeichen im Wert: ssh trennt UserKnownHostsFile an Leerraum; ~ setzt ssh selbst ein.
SSH_BEKANNTE = "~/.ssh/known_hosts_trading_dh"
SSH_TIMEOUT_S = 120
# Seit Paket IB2: die IBKR-Instanz als zweiter Teil mit eigener Ergebnisdatei und eigener Zeile.
TEILE = ("alpaca", "ibkr")
# Der erste Tag, den der Teil ibkr prueft; gesetzt beim Einspielen von IB2 (Planuebersicht IB2).
IBKR_ERSTER_TAG = date(2026, 9, 25)
IBKR_ENDUNG = "-ibkr"
KOEPFE = {
"alpaca": "Abendkontrolle Trading Plattform (automatisch)",
"ibkr": "Abendkontrolle IBKR (automatisch)",
}
def faellige_tage(jetzt: datetime, ablage: Path, erster_tag: date = ERSTER_TAG, endung: str = "") -> list[str]:
tage = []
for zurueck in range(NACHHOLEN_TAGE, -1, -1):
tag = jetzt.date() - timedelta(days=zurueck)
if tag < erster_tag or tag.weekday() >= 5:
continue
if tag == jetzt.date() and jetzt.time() < FRUEHESTENS:
continue
if (ablage / f"{tag.isoformat()}{endung}.json").exists():
continue
tage.append(tag.isoformat())
return tage
def fahre_wsl(tag: str, teil: str = "alpaca") -> dict:
lauf = subprocess.run(
[WSL, "-d", "Debian", "-u", "claude", "--", WSL_PY, WSL_SKRIPT, "--tag", tag, "--teil", teil],
capture_output=True, timeout=WSL_TIMEOUT_S, creationflags=KEIN_FENSTER,
)
ausgabe = lauf.stdout.decode("utf-8", errors="replace")
for zeile in reversed(ausgabe.splitlines()):
zeile = zeile.strip()
if zeile.startswith("{"):
try:
return json.loads(zeile)
except json.JSONDecodeError:
break
fehler = lauf.stderr.decode("utf-8", errors="replace").strip().splitlines()[-3:]
raise RuntimeError(f"keine JSON-Zeile vom WSL-Teil (Exit {lauf.returncode}; {' | '.join(fehler)[:300]})")
def fahre_dockerhost(tag: str, teil: str = "alpaca") -> dict:
"""Liest das Ergebnis, das die Abendkontrolle auf dem Docker-Host fuer <tag> und <teil> abgelegt hat.
Der Host legt je Tag <tag>.json (alpaca) und <tag>-ibkr.json (ibkr) ab; angefragt wird mit
genau dem Namen ohne .json. Exit 3 von abendkontrolle-lesen.sh heisst: Dafuer liegt dort noch
nichts. Das ist ein Fehlschlag wie jeder andere -- die Notiz bekommt #needs-review, und der
naechste Lauf versucht es erneut. BatchMode: nie eine Rueckfrage. StrictHostKeyChecking=yes:
nur der bekannte Host.
"""
ziel = tag + (IBKR_ENDUNG if teil == "ibkr" else "")
lauf = subprocess.run(
[
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,
ziel,
],
capture_output=True, timeout=SSH_TIMEOUT_S, creationflags=KEIN_FENSTER,
)
if lauf.returncode == 3:
raise RuntimeError(
f"auf dem Docker-Host liegt fuer {ziel} noch kein Ergebnis "
"(dort laeuft die Abendkontrolle werktags 16:30 New Yorker Zeit)"
)
ausgabe = lauf.stdout.decode("utf-8", errors="replace")
for zeile in reversed(ausgabe.splitlines()):
zeile = zeile.strip()
if zeile.startswith("{"):
try:
return json.loads(zeile)
except json.JSONDecodeError:
break
fehler = lauf.stderr.decode("utf-8", errors="replace").strip().splitlines()[-3:]
raise RuntimeError(f"keine JSON-Zeile vom Docker-Host (Exit {lauf.returncode}; {' | '.join(fehler)[:300]})")
def zeile_fuer(ergebnis: dict, uhrzeit: str) -> str:
kopf = f"- {uhrzeit} Uhr — Abendkontrolle Trading Plattform (automatisch)"
befunde = ergebnis.get("befunde") or []
fakten = ergebnis.get("fakten") or {}
hinweise = ergebnis.get("hinweise") or []
if fakten.get("handelstag") is False and not befunde:
return f"{kopf}: kein Handelstag laut Alpaca-Kalender."
teile = []
ende = fakten.get("ende") or {}
if ende:
teile.append(
f"Ende {ende.get('grund')}, Exit {ende.get('exit')}, Orders {ende.get('orders')}, "
f"Position {ende.get('position')}"
)
laeufe = fakten.get("laeufe") or []
if laeufe:
teile.append(f"{len(laeufe)} Lauf/Läufe, {sum(l.get('takte_aktiv', 0) for l in laeufe)} Takte aktiv")
if "broker_offen" in fakten:
teile.append(f"offene Positionen beim Broker {fakten['broker_offen']}")
if "bericht" in fakten:
teile.append("Tagesbericht da" if fakten["bericht"] else "Tagesbericht fehlt")
killswitch = fakten.get("killswitch") or {}
if killswitch:
teile.append(f"Killswitch {killswitch.get('zustand')} ({killswitch.get('ausloeser')})")
return _zeile(kopf, befunde, "; ".join(teile), fakten, hinweise)
def _zeile(kopf: str, befunde: list, zusammen: str, fakten: dict, hinweise: list) -> str:
"""Kopf, Urteil, Zusammenfassung, Befunde und Hinweise -- fuer beide Teile dieselbe Form."""
if not befunde:
text = f"{kopf}: ohne Befund. {zusammen}."
else:
text = f"{kopf}: **{len(befunde)} Befund(e)** #needs-review. {zusammen}."
text += "".join(f"\n - {befund}" for befund in befunde)
if fakten.get("meldung"):
text += f"\n - Meldung ans Telefon: {fakten['meldung']}"
text += "".join(f"\n - Hinweis: {hinweis}" for hinweis in hinweise[:5])
return text
def zeile_fuer_ibkr(ergebnis: dict, uhrzeit: str) -> str:
"""Die eigene Zeile der IBKR-Instanz (seit Paket IB2). Kein Betrag, keine Kontonummer."""
kopf = f"- {uhrzeit} Uhr — {KOEPFE['ibkr']}"
befunde = ergebnis.get("befunde") or []
fakten = ergebnis.get("fakten") or {}
hinweise = ergebnis.get("hinweise") or []
if fakten.get("handelstag") is False and not befunde:
return f"{kopf}: kein Handelstag laut Alpaca-Kalender."
if fakten.get("nicht_gestartet") and not befunde:
return f"{kopf}: IBKR-Instanz heute nicht gestartet (Zeitgeber aus)."
teile = []
ende = fakten.get("ende") or {}
if ende:
teile.append(
f"Ende {ende.get('grund')}, Exit {ende.get('exit')}, Orders {ende.get('orders')}, "
f"Position {ende.get('position')}"
)
laeufe = fakten.get("laeufe") or []
if laeufe:
teile.append(f"{len(laeufe)} Lauf/Läufe, {sum(l.get('takte_aktiv', 0) for l in laeufe)} Takte aktiv")
konto = fakten.get("konto") or {}
if konto:
teile.append(
f"Konto beim Gateway: {konto.get('positionen')} Position(en), "
f"{konto.get('orders')} offene Order(s)"
)
killswitch = fakten.get("killswitch") or {}
if killswitch:
teile.append(f"Killswitch {killswitch.get('zustand')} ({killswitch.get('ausloeser')})")
return _zeile(kopf, befunde, "; ".join(teile), fakten, hinweise)
def schreibe_notiz(vault: Path, tag: str, zeile: str) -> None:
notiz = vault / "Inbox" / f"{tag}.md"
if notiz.exists():
text = notiz.read_bytes().decode("utf-8") # kein UTF-8: Ausnahme, die Notiz bleibt unberuehrt
else:
text = (
f"---\ntype: daily\ntitle: \"{tag}\"\nstatus: open\nsummary: \"Tagesnotiz vom {tag}.\"\n"
f"tags: [daily]\ncreated: {tag}\nupdated: {tag}\n---\n\n# {tag}\n"
)
# Zeilenenden der Datei bleiben, wie sie sind; gesucht wird ohne \r.
zeilen = text.split("\n")
bereinigt = [z.rstrip("\r").rstrip() for z in zeilen]
cr = "\r" if any(z.endswith("\r") for z in zeilen) else ""
# Die erste Ueberschrift, die mit "## Agentenprotokoll" beginnt ("## Agentenprotokoll — …" kommt vor).
start = next((i for i, z in enumerate(bereinigt) if z == UEBERSCHRIFT or z.startswith(UEBERSCHRIFT + " ")), None)
if start is None:
nl = cr + "\n"
text = text.rstrip("\r\n") + nl + nl + UEBERSCHRIFT + nl + nl + nl.join(zeile.split("\n")) + nl
else:
ende = next((i for i in range(start + 1, len(zeilen)) if bereinigt[i].startswith("## ")), len(zeilen))
# Hinter den letzten nicht leeren Eintrag des Abschnitts; die Leerzeilen danach ersetzt eine.
einfuegen = ende
while einfuegen > start + 1 and not bereinigt[einfuegen - 1]:
einfuegen -= 1
block = [z + cr for z in zeile.split("\n")]
if einfuegen == start + 1:
block = [cr] + block
zeilen[einfuegen:ende] = block + ([cr] if ende < len(zeilen) else [""])
text = "\n".join(zeilen)
if not text.endswith("\n"):
text += "\n"
neu = notiz.with_name(notiz.name + ".abendkontrolle.tmp")
neu.write_bytes(text.encode("utf-8"))
os.replace(neu, notiz)
def logge(ablage: Path, text: str) -> None:
ablage.mkdir(parents=True, exist_ok=True)
with (ablage / "lauf.log").open("a", encoding="utf-8", newline="\n") as datei:
datei.write(f"{datetime.now().isoformat(timespec='seconds')} {text}\n")
def main(argv=None) -> int:
zerleger = argparse.ArgumentParser(allow_abbrev=False)
zerleger.add_argument("--tag")
zerleger.add_argument("--vault", default=str(VAULT))
zerleger.add_argument("--ohne-wsl", help="Ergebnis aus einer JSON-Datei statt aus WSL (Angriffsfaelle)")
zerleger.add_argument("--jetzt", help="ISO-Zeitpunkt statt der Uhr (Angriffsfaelle)")
zerleger.add_argument("--teil", choices=TEILE, help="nur diesen Teil; ohne Angabe beide, mit --ohne-wsl nur alpaca")
zerleger.add_argument(
"--quelle", choices=QUELLEN, default="wsl",
help="wsl (Vorgabe): pruefen in WSL; dockerhost: das Ergebnis des Docker-Hosts ueber SSH lesen",
)
zerleger.add_argument(
"--quelle-ibkr", choices=QUELLEN,
help="die Quelle fuer den Teil ibkr; ohne Angabe dieselbe wie --quelle",
)
argumente = zerleger.parse_args(argv)
vault = Path(argumente.vault)
ablage = vault / ABLAGE_TEIL
jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now()
# Ohne --teil beide Teile, erst alpaca, dann ibkr. Mit --ohne-wsl ohne --teil nur alpaca: Eine
# Ergebnisdatei ist immer die eines Teils, und die Angriffsfaelle von vor IB2 bleiben gleich.
teile = [argumente.teil] if argumente.teil else (["alpaca"] if argumente.ohne_wsl else list(TEILE))
code = 0
for teil in teile:
if argumente.tag:
tage = [argumente.tag]
elif teil == "ibkr":
tage = faellige_tage(jetzt, ablage, erster_tag=IBKR_ERSTER_TAG, endung=IBKR_ENDUNG)
else:
tage = faellige_tage(jetzt, ablage)
vorsatz = "" if teil == "alpaca" else f"{teil} "
# Scheitert ein Teil ausserhalb seiner eigenen Fehlerbehandlung (Ergebnisdatei nicht
# schreibbar, lauf.log gesperrt), laeuft der naechste trotzdem (Pruefung IB2).
try:
logge(ablage, f"{vorsatz}start, faellig: {tage}")
# Seit Paket DH: die Quelle je Teil; die IBKR-Instanz zieht an einem eigenen Abend um.
quelle = (argumente.quelle_ibkr or argumente.quelle) if teil == "ibkr" else argumente.quelle
code = max(code, fahre_teil(teil, tage, jetzt, vault, ablage, argumente.ohne_wsl, quelle))
except Exception as ausnahme:
code = 1
try:
logge(ablage, f"{vorsatz}abgebrochen: {type(ausnahme).__name__}")
except Exception:
pass
return code
def fahre_teil(teil: str, tage: list[str], jetzt: datetime, vault: Path, ablage: Path, ohne_wsl: str | None,
quelle: str = "wsl") -> int:
"""Ein Teil fuer alle faelligen Tage: Ergebnisdatei, Zeile in der Notiz, lauf.log.
quelle (seit Paket DH): wsl prueft in WSL, dockerhost liest das Ergebnis des Docker-Hosts.
"""
endung = IBKR_ENDUNG if teil == "ibkr" else ""
zeile_von = zeile_fuer_ibkr if teil == "ibkr" else zeile_fuer
vorsatz = "" if teil == "alpaca" else f"{teil} "
code = 0
for tag in tage:
uhrzeit = jetzt.strftime("%H:%M") if tag == jetzt.date().isoformat() else f"{jetzt:%Y-%m-%d %H:%M}"
try:
if ohne_wsl:
ergebnis = json.loads(Path(ohne_wsl).read_text(encoding="utf-8"))
elif quelle == "dockerhost":
ergebnis = fahre_dockerhost(tag, teil)
else:
ergebnis = fahre_wsl(tag, teil)
if not isinstance(ergebnis, dict) or not isinstance(ergebnis.get("befunde"), list):
raise RuntimeError("Ergebnis des WSL-Teils in unerwarteter Form")
# Ein Ergebnis ohne teil stammt von einem WSL-Teil vor IB2 und gilt als alpaca. Passt
# der Teil nicht, landete es sonst still in der falschen Ergebnisdatei und Zeile.
if ergebnis.get("teil", "alpaca") != teil:
raise RuntimeError(f"Ergebnis des WSL-Teils gehoert zu {ergebnis.get('teil')!r}, nicht zu {teil!r}")
except Exception as ausnahme:
code = 1
logge(ablage, f"{vorsatz}{tag}: gescheitert {ausnahme!r}\n{traceback.format_exc()}")
try:
schreibe_notiz(
vault, tag,
f"- {uhrzeit} Uhr — {KOEPFE[teil]}: **gescheitert** "
f"({type(ausnahme).__name__}: {str(ausnahme)[:200]}) #needs-review. Der nächste Lauf versucht es erneut.",
)
except Exception as zweite:
logge(ablage, f"{vorsatz}{tag}: auch die Notiz scheiterte {zweite!r}")
continue
# Ein Abbruch im WSL-Teil (Exit 2) wird wiederholt; alles andere gilt als gelaufen.
if ergebnis.get("exit") != 2:
(ablage / f"{tag}{endung}.json").write_text(
json.dumps(ergebnis, ensure_ascii=False, indent=1), encoding="utf-8", newline="\n"
)
else:
code = 1
logge(ablage, f"{vorsatz}{tag}: {len(ergebnis['befunde'])} Befund(e), exit {ergebnis.get('exit')}")
try:
schreibe_notiz(vault, tag, zeile_von(ergebnis, uhrzeit))
except Exception as ausnahme:
code = 1
logge(ablage, f"{vorsatz}{tag}: Notiz nicht geschrieben {ausnahme!r}")
return code
if __name__ == "__main__":
sys.exit(main())