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
This commit is contained in:
Pascal MichelandClaude Opus 5.5 committed 2026-10-11 17:09:14 +02:00
1 parent c5db92c6de
commit 3e55bb915d
3 files changed
+514 -3

No files matched your search

@@ -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 <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) -> 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.
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>"
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)"