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
This commit is contained in:
Pascal MichelandClaude Opus 5.5 committed 2026-10-11 16:58:56 +02:00
1 parent 77de5059e4
commit c5db92c6de
3 files changed
+1174 -7

No files matched your search

@@ -29,13 +29,21 @@ 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,
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).
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 hashlib
import json
import os
import re
import subprocess
import sys
import traceback
@@ -44,6 +52,7 @@ from datetime import datetime
from datetime import time
from datetime import timedelta
from pathlib import Path
from time import monotonic
VAULT = Path(r"D:\Claude")
PROJEKT_TEIL = Path("Code Projekte") / "active - Trading Plattform"
@@ -82,6 +91,34 @@ 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.
# (S3). 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 faellige_tage(jetzt: datetime, ablage: Path, erster_tag: date = ERSTER_TAG, endung: str = "") -> list[str]:
tage = []
for zurueck in range(NACHHOLEN_TAGE, -1, -1):
@@ -155,6 +192,227 @@ def fahre_dockerhost(tag: str, teil: str = "alpaca") -> dict:
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()
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.
"""
if stdout is None:
return subprocess.run(ssh_aufruf(befehl), capture_output=True, timeout=timeout, creationflags=KEIN_FENSTER)
return subprocess.run(
ssh_aufruf(befehl), stdout=stdout, stderr=subprocess.PIPE, timeout=timeout, creationflags=KEIN_FENSTER,
)
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: nur JJJJ-MM-TT.
if not TAGFORM.fullmatch(tag):
raise ValueError(f"--tag {tag!r} ist kein Datum JJJJ-MM-TT")
date.fromisoformat(tag)
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:
lauf = ssh_lauf(f"sicherung {name}", SICHERUNG_TIMEOUT_S, strom)
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 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:
kopf = f"- {uhrzeit} Uhr — Abendkontrolle Trading Plattform (automatisch)"
befunde = ergebnis.get("befunde") or []
@@ -284,7 +542,14 @@ def main(argv=None) -> int:
"--quelle-ibkr", choices=QUELLEN,
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)
# 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)
ablage = vault / ABLAGE_TEIL
jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now()
@@ -313,6 +578,21 @@ def main(argv=None) -> int:
logge(ablage, f"{vorsatz}abgebrochen: {type(ausnahme).__name__}")
except Exception:
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