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

@@ -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", "<html>", 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)
@@ -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
@@ -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)"