"""Killswitch-Simulation: die echte Funktion _generalprobe aus scripts/paper.py, ohne Netz und ohne das echte Konto. Koordinator-Pruefcode zur Trading Plattform, nicht Teil des Projekt-Repos. Entstanden am 2026-09-21 auf Pascals Auftrag "fuehre selbststaendig eine Killswitch-Simulation durch". Die Killswitch-UEBUNG des Projekts (scripts/killswitch_uebung.py) ist dafuer das falsche Werkzeug, sobald der Paper-Runner als Dienst laeuft: Sie stellt echte Orders und schreibt ins gemeinsame Register. Diese Simulation fasst weder das Konto noch das echte Register an. VERDRAHTUNG (wie generalprobe_echtlauf.py): Gerufen wird die echte Funktion _generalprobe -- dieselbe Schleife, derselbe Agent aus dem Register, dieselben Grenzen wie im Betrieb. Umgebogen sind nur die Stellen, die sonst ins Netz oder in den Betrieb gingen: REGISTER zeigt auf eine Sicherungskopie im Wegwerf-Ordner (das echte wird nur lesend geoeffnet), Statusdatei und Protokoll liegen im Wegwerf-Ordner, die Kerzen kommen aus einer JSON-Datei (hole_kerzen.py), AlpacaBroker.from_secrets liefert ein mitschreibendes Konto, das glatt ist und bei jedem schreibenden Aufruf wirft, requests.Session.request wirft, und der Melder schreibt in einen mitschreibenden Transport. "Pascal" klickt aus einem zweiten Faden -- wie das Dashboard mit einem frischen Killswitch-Objekt aus dem Register. SZENARIEN hand Pascal klickt alle fuenf Stufen: fortsetzen, anhalten, fortsetzen, glattstellen, freigeben. sturz Ab 11:00 Uhr New Yorker Zeit stehen alle Kerzen zehn Prozent tiefer: Der Runner muss die Tagesverlustgrenze selbst reissen, glattstellen und sperren -- und nach Pascals "Freigeben" gesperrt bleiben, solange der Verlust steht, ohne neue Order. neustart Das Dashboard startet mitten am Tag neu (Killswitch.laden, Ausloeser neustart): Der Runner pausiert mit stehender Position, meldet die Pause, und die Latenzanzeige zaehlt den Neustart nicht als Klick. WAS GEPRUEFT WIRD, UND WOHER DIE ERWARTUNG STAMMT - Eine Order entsteht nur in einem Takt, der im Zustand aktiv endet; kein Takt ausserhalb von aktiv traegt die Phase handelt oder schliesst. Projektvorgabe: docs/code/killswitch.md, docs/code/paper.md - Anhalten laesst die Position stehen; Glattstellen schliesst sie, sperrt, und gesperrt bleibt es bis zum Freigeben. Projektvorgabe: Administrator-Anleitung, "Killswitch ueben" - Tagesverlust: Der Runner stellt selbst glatt (Ausloeser paper-runner), meldet "Grenze verletzt" mit "Killswitch: gesperrt", und sperrt nach einem Freigeben im selben Takt wieder. Quelle: trading/paper/sitzung.py, _grenzverletzung und der Takt - Ein Neustart ist kein Klick; die Pause wird gemeldet, wenn nicht Pascal gedrueckt hat. Quelle: trading/paper/sitzung.py, KLICK_AUSLOESER und _melde_pause_von_aussen - Kein Netzversuch, am "echten" Konto nur die drei lesenden Aufrufe der Startbedingung, jede Meldung mit dem Vorsatz der Generalprobe, keine tagesstart.json, Position am Ende null, Exit 0. Projektvorgabe: Spec der Generalprobe (Paket G) - Reaktion auf einen Klick hoechstens zwei Takte plus eine Sekunde. Setzung dieses Werkzeugs, keine Projektvorgabe GRENZEN -- ein Fangnetz, kein Nachweis Gefuellt wird gespielt: Der Weg einer echten Order zu Alpaca (Annahme, Teilausfuehrung, Zeitlimit) kommt nicht vor. Der Klick geht nicht ueber HTTP durch das Dashboard, sondern ueber dasselbe Killswitch-Objekt, das das Dashboard baut. Der Watchdog laeuft mit, wird aber nicht gezielt ausgeloest. Eine Short-Position kommt mit dem Agenten im Betrieb (konstante Politik, Stufe +1) nicht vor. REGRESSIONSNACHWEIS: killswitch_simulation_mutanten.py macht den Betriebscode an sechs Stellen kaputt; die Simulation muss jeden Mutanten mit Exit 2 melden. Ergebnis und Datum stehen in der LIESMICH. Exit 0: kein Befund. Exit 2: Befunde (auch ein Absturz ist ein Befund und steht in der Liste). Aufruf (WSL, Venv des Projekts): python killswitch_simulation.py [tempo] """ import importlib.util import json import os import sqlite3 import sys import threading import time import traceback from dataclasses import replace from datetime import UTC from datetime import datetime from datetime import timedelta from pathlib import Path STURZ_AB = (11, 0) STURZ_FAKTOR = 0.90 PLAENE = { "hand": [("fortsetzen", (9, 31)), ("anhalten", (10, 30)), ("fortsetzen", (10, 36)), ("glattstellen", (12, 0)), ("freigeben", (12, 10))], "sturz": [("fortsetzen", (9, 31)), ("freigeben", (13, 0))], "neustart": [("fortsetzen", (9, 31)), ("NEUSTART", (10, 30)), ("fortsetzen", (10, 40))], } BEFUNDE: list[str] = [] NETZVERSUCHE: list[str] = [] def pruefe(bedingung, satz): if not bedingung: BEFUNDE.append(satz) def main() -> None: if len(sys.argv) < 6 or sys.argv[1] not in PLAENE: BEFUNDE.append("Aufruf: killswitch_simulation.py " " [tempo]") return szenario = sys.argv[1] code_baum = Path(sys.argv[2]).resolve() echt = Path(sys.argv[3]).resolve() kerzen_datei = Path(sys.argv[4]).resolve() weg = Path(sys.argv[5]).resolve() tempo = float(sys.argv[6]) if len(sys.argv) > 6 else 1.0 # Eine fehlende Voraussetzung ist ein Befund, kein Grund zu schweigen. for pfad, was in ((code_baum / "scripts" / "paper.py", "Code-Baum ohne scripts/paper.py"), (echt / "data" / "registry.db", "echter Projektordner ohne data/registry.db"), (kerzen_datei, "Kerzendatei fehlt")): if not pfad.exists(): BEFUNDE.append(f"Voraussetzung fehlt: {was}") if weg.exists(): BEFUNDE.append("Voraussetzung fehlt: Der Wegwerf-Ordner existiert schon; die Simulation schreibt nur in einen neuen") if BEFUNDE: return os.chdir(code_baum) sys.path.insert(0, str(code_baum)) import requests from trading.broker.schnittstelle import Marktuhr from trading.killswitch.killswitch import Killswitch from trading.killswitch.killswitch import lade_killswitch_config from trading.paper.config import lade_paper_config from trading.paper.kerzen import HANDELSZONE from trading.paper.kerzen import Kerze from trading.paper.melder import Melder from trading.paper.melder import MelderConfig from trading.paper.status import lies_status from trading.registry.registry import Registry def kein_netz(self, method, url, *args, **kwargs): NETZVERSUCHE.append(f"{method} {url.split('?')[0]}") raise requests.ConnectionError("Simulation: kein Netz") requests.sessions.Session.request = kein_netz class Antwort: status_code = 200 class Transport: def __init__(self): self.aufrufe = [] def __call__(self, url, *, data=None, headers=None, timeout=None, **rest): rumpf = data.decode("utf-8") if isinstance(data, bytes) else str(data) self.aufrufe.append({"titel": headers.get("Title"), "prioritaet": headers.get("Priority"), "zeilen": rumpf.split("\n")}) return Antwort() class MitschreibendesKonto: """Steht fuer das echte Alpaca-Paper-Konto: glatt, keine Order, Boerse zu. Schreiben wirft.""" def __init__(self, name): self.name = name self.aufrufe = [] def positionen(self): self.aufrufe.append("positionen") return [] def offene_orders(self): self.aufrufe.append("offene_orders") return [] def marktuhr(self): self.aufrufe.append("marktuhr") jetzt = datetime.now(UTC) return Marktuhr(is_open=False, next_open=jetzt + timedelta(hours=8), next_close=jetzt + timedelta(hours=14)) def storniere_alle(self): self.aufrufe.append("storniere_alle") return [] def schliesse_alle(self): self.aufrufe.append("schliesse_alle") return [] def __getattr__(self, name): def wirft(*a, **k): self.aufrufe.append(f"VERBOTEN:{name}") raise AssertionError(f"{self.name}: unerwarteter Aufruf {name}") return wirft def ortszeit(text): return datetime.fromisoformat(text).astimezone(HANDELSZONE) roh = json.loads(kerzen_datei.read_text(encoding="utf-8")) if not isinstance(roh, list) or not roh: BEFUNDE.append("Voraussetzung fehlt: Die Kerzendatei ist keine nicht leere Liste") return kerzen = [Kerze(zeit=datetime.fromisoformat(e["zeit"]), open=e["open"], high=e["high"], low=e["low"], close=e["close"], volume=e["volume"]) for e in roh] if szenario == "sturz": gestuerzt = [] for kerze in kerzen: ort = kerze.zeit.astimezone(HANDELSZONE) if (ort.hour, ort.minute) >= STURZ_AB: kerze = replace(kerze, open=kerze.open * STURZ_FAKTOR, high=kerze.high * STURZ_FAKTOR, low=kerze.low * STURZ_FAKTOR, close=kerze.close * STURZ_FAKTOR) gestuerzt.append(kerze) kerzen = gestuerzt tag = kerzen[0].zeit.astimezone(HANDELSZONE).date().isoformat() weg.mkdir(parents=True, exist_ok=False) # Sicherungskopie des echten Registers; das echte wird nur lesend geoeffnet. quelle = sqlite3.connect(f"file:{echt / 'data' / 'registry.db'}?mode=ro", uri=True) ziel = sqlite3.connect(weg / "registry.db") quelle.backup(ziel) quelle.close() # artefakt_pfad ist relativ zum Projektordner; in der Kopie wird er absolut, damit der Lauf aus dem Code-Baum # heraus das Registermodell im echten artifacts-Ordner findet (nur lesen). for zeile in ziel.execute("SELECT agent, version, artefakt_pfad FROM versionen").fetchall(): pfad = Path(zeile[2].replace("\\", "/")) if not pfad.is_absolute(): ziel.execute("UPDATE versionen SET artefakt_pfad = ? WHERE agent = ? AND version = ?", (str(echt / pfad), zeile[0], zeile[1])) ziel.commit() ziel.close() ereignisse_vorher = len(Registry(db_pfad=weg / "registry.db", artefakt_wurzel=echt / "artifacts").killswitch_ereignisse()) spec = importlib.util.spec_from_file_location("paper_skript_simulation", code_baum / "scripts" / "paper.py") paper = importlib.util.module_from_spec(spec) spec.loader.exec_module(paper) paper.REGISTER = str(weg / "registry.db") paper.ARTEFAKTE = str(echt / "artifacts") konto_der_probe = MitschreibendesKonto("Konto der Generalprobe") konto_des_dashboards = MitschreibendesKonto("Konto des Dashboards") class Kerzenquelle: @classmethod def from_secrets(cls, *a, **k): return cls() def kerzen_des_tages(self, instrument_id, ende): return list(kerzen) class BrokerFabrik: @classmethod def from_secrets(cls, *a, **k): return konto_der_probe paper.AlpacaKerzenquelle = Kerzenquelle paper.AlpacaBroker = BrokerFabrik config = replace(lade_paper_config("config/paper.toml"), status_datei=str(weg / "paper" / "status.json"), protokoll_ordner=str(weg / "protokolle")) ks_config = lade_killswitch_config("config/killswitch.toml") transport = Transport() melder = Melder(config=MelderConfig(aktiv=True, ntfy_url="https://ntfy.sh", thema="simulation-geht-nie-ins-netz", zeitlimit_s=3.0, stille_nach_s=180.0), token="", transport=transport) klicks = [] modi = set() halt = threading.Event() def dashboard_killswitch(): registry = Registry(db_pfad=weg / "registry.db", artefakt_wurzel=echt / "artifacts") gespeichert = registry.killswitch_zustand() return Killswitch(broker=konto_des_dashboards, registry=registry, config=ks_config, zustand=gespeichert.zustand if gespeichert is not None else "angehalten") def pascal(): plan = list(PLAENE[szenario]) while not halt.is_set(): status = lies_status(config.status_datei) if isinstance(status, dict) and status.get("marktzeit"): modi.add(status.get("modus")) try: ort = ortszeit(status["marktzeit"]) except (ValueError, TypeError): time.sleep(0.2) continue if plan and ort.date().isoformat() == tag and (ort.hour, ort.minute) >= plan[0][1]: was, _ = plan.pop(0) eintrag = {"was": was, "marktzeit": f"{ort:%H:%M}", "position_vorher": status.get("position")} try: if was == "NEUSTART": registry = Registry(db_pfad=weg / "registry.db", artefakt_wurzel=echt / "artifacts") eintrag["vorher"] = registry.killswitch_zustand().zustand neu_geladen = Killswitch.laden(broker=konto_des_dashboards, registry=registry, config=ks_config) eintrag["nachher"] = neu_geladen.zustand eintrag["erfolgreich"] = True else: ks = dashboard_killswitch() eintrag["vorher"] = ks.zustand if was == "glattstellen": ergebnis = ks.glattstellen("pascal", "Killswitch-Simulation", ks_config.bestaetigungswort) else: ergebnis = getattr(ks, was)("pascal", "Killswitch-Simulation") eintrag["nachher"] = ergebnis.zustand eintrag["erfolgreich"] = ergebnis.erfolgreich except Exception as ausnahme: # noqa: BLE001 eintrag["ausnahme"] = repr(ausnahme) klicks.append(eintrag) time.sleep(0.2) faden = threading.Thread(target=pascal, name="pascal", daemon=True) faden.start() beginn = time.monotonic() code = paper._generalprobe(config, tag, tempo, melder) dauer = time.monotonic() - beginn time.sleep(0.5) halt.set() faden.join(timeout=5) # ---------------------------------------------------------------- Auswertung zeilen = [] for pfad in sorted((weg / "protokolle").glob("*.jsonl")): with pfad.open(encoding="utf-8") as datei: zeilen += [json.loads(zeile) for zeile in datei if zeile.strip()] takte = [z for z in zeilen if z.get("art") == "takt"] ende = next((z for z in zeilen if z.get("art") == "ende"), {}) klickzeilen = [z for z in zeilen if z.get("art") == "latenz" and z.get("was") == "klick_reaktion"] grenzereignisse = [z for z in zeilen if z.get("art") == "ereignis" and z.get("was") == "grenzwert"] # Jeder Orderzeile den Takt zuordnen, in dem sie entstand: Die Taktzeile schliesst den Takt ab, steht also danach. order_takte = [] for nummer, zeile in enumerate(zeilen): if zeile.get("art") == "order": order_takte.append((zeile, next((z for z in zeilen[nummer + 1:] if z.get("art") == "takt"), None))) registry = Registry(db_pfad=weg / "registry.db", artefakt_wurzel=echt / "artifacts") ereignisse = registry.killswitch_ereignisse()[ereignisse_vorher:] schluss = lies_status(config.status_datei) print("=" * 78) print(f"Szenario {szenario}; Code {code_baum.name}; Tag {tag}, {len(kerzen)} Kerzen, {tempo:g} s je Marktminute, " f"echte Dauer {dauer:.0f} s, Exit {code}") print(f"Ende laut Protokoll: grund={ende.get('grund')} killswitch={ende.get('killswitch')} position={ende.get('position')} " f"orders={ende.get('orders')} schritte={ende.get('schritte')} " f"ergebnis={None if ende.get('kontowert') is None else round(ende['kontowert'] - ende['startkapital'], 2)} (gespielt)") print("Klicks (Marktzeit New York):") for k in klicks: print(f" {k}") print("Reaktion des Runners je Wechsel (klick_reaktion, echte Sekunden):") for z in klickzeilen: print(f" {z.get('sekunden')} s -> zustand {z.get('zustand')} (ausloeser {z.get('ausloeser')}, phase {z.get('phase')})") print("Zustandsverlauf in den Taktzeilen (Marktzeit der letzten Kerze, Killswitch, Phase, Position):") letzter = None for t in takte: schluessel = (t.get("killswitch"), t.get("phase"), t.get("position")) if schluessel != letzter: print(f" Takt {t.get('schritt'):>3} {ortszeit(t['letzte_kerze']):%H:%M} {str(t.get('killswitch')):<11} " f"{str(t.get('phase')):<15} Position {t.get('position'):>3} gespieltes Ergebnis " f"{round(t['kontowert'] - t['startkapital'], 2)}") letzter = schluessel print("Orders (mit dem Killswitch-Zustand ihres Takts):") for zeile, folgetakt in order_takte: print(f" {zeile.get('id')} {zeile.get('seite')} {zeile.get('menge')} -> {zeile.get('status')}, Position danach " f"{zeile.get('position_danach')}; Takt {None if folgetakt is None else folgetakt.get('schritt')} " f"killswitch={None if folgetakt is None else folgetakt.get('killswitch')} " f"phase={None if folgetakt is None else folgetakt.get('phase')}") print("Grenzereignisse im Protokoll:") for z in grenzereignisse: print(f" {z.get('zeit')} {z.get('text')} (phase {z.get('phase')})") print("Killswitch-Ereignisse dieses Laufs im Wegwerf-Register:") for e in ereignisse: print(f" {e.stufe:<13} {e.ausloeser:<13} erfolgreich={int(e.erfolgreich)} {e.grund[:70]}") print(f"Killswitch am Ende im Register: {registry.killswitch_zustand().zustand}") print(f"Meldungen ({len(transport.aufrufe)}):") for m in transport.aufrufe: kern = [zeile for zeile in m["zeilen"] if zeile.startswith(("Grund:", "Killswitch:", "Position:"))] print(f" [{m['prioritaet']}] {m['titel']} | {' | '.join(kern)}") print(f"Aufrufe am Konto der Generalprobe: {konto_der_probe.aufrufe}") print(f"Aufrufe am Konto des Dashboards: {konto_des_dashboards.aufrufe}") print(f"Netzversuche: {NETZVERSUCHE}") # ---------------------------------------------------------------- Pruefungen pruefe(bool(takte), "kein einziger Takt im Protokoll") pruefe(code == 0, f"Exit {code} statt 0") pruefe(modi == {"generalprobe"}, f"Modi {modi}") pruefe(not NETZVERSUCHE, "Netzversuch") pruefe(sorted(konto_der_probe.aufrufe) == ["marktuhr", "offene_orders", "positionen"], "Konto der Generalprobe: andere Aufrufe als die drei lesenden") pruefe(not any(a.startswith("VERBOTEN") for a in konto_des_dashboards.aufrufe), "Konto des Dashboards: verbotener Aufruf") pruefe(bool(transport.aufrufe) and all(str(m["titel"]).startswith("Generalprobe: ") for m in transport.aufrufe), "keine Meldung, oder eine ohne den Vorsatz der Generalprobe") pruefe(not list(weg.rglob("tagesstart.json")), "tagesstart.json entstanden") pruefe(ende.get("position") == 0 and isinstance(schluss, dict) and schluss.get("position") == 0, "Position am Ende nicht null") pruefe(len(klicks) == len(PLAENE[szenario]) and all("ausnahme" not in k for k in klicks), "nicht jeder Klick kam an") pruefe(all(k.get("erfolgreich") for k in klicks), "ein Klick meldet erfolgreich=False") pruefe(bool(order_takte), "keine einzige Order -- ohne Handel misst die Simulation den Killswitch nicht") # Ein Klick kann mitten in einen Takt fallen. Dann traegt dessen Taktzeile schon den neuen Zustand, aber noch die # Phase und den lokalen Bestand von vorher: Die Sitzung liest den Zustand mehrmals je Takt, und die Zeile entsteht # am Taktende. Gemessen am 2026-09-21 im Schnelltempo: Takt 152 "gesperrt / handelt / Position 10" -- ohne # Entscheidung und ohne Order, im naechsten Takt "pausiert / Position 0". Verbindlich ist deshalb alles AB DEM # ZWEITEN Takt eines nicht aktiven Abschnitts; der erste wird ausgewiesen, nicht beurteilt. Eine Order in diesem # ersten Takt ist ein Wettlauf zwischen Klick und Order (die Order ging unter 'aktiv' hinaus) und wird als Hinweis # gedruckt -- im echten Betrieb faengt 'Anhalten' sie mit dem Stornieren, 'Glattstellen' mit dem Schliessen. hinweise = [] erste_takte = set() vorher_aktiv = True for t in takte: nicht_aktiv = t.get("killswitch") != "aktiv" if nicht_aktiv and vorher_aktiv: erste_takte.add(t.get("schritt")) vorher_aktiv = not nicht_aktiv for zeile, folgetakt in order_takte: if folgetakt is not None and folgetakt.get("killswitch") != "aktiv" and folgetakt.get("schritt") in erste_takte: hinweise.append(f"Wettlauf: Order {zeile.get('id')} und Zustandswechsel im selben Takt {folgetakt.get('schritt')}") continue pruefe(folgetakt is not None and folgetakt.get("killswitch") == "aktiv", f"Order {zeile.get('id')} in einem Takt mit Killswitch {None if folgetakt is None else folgetakt.get('killswitch')}") for t in takte: if t.get("killswitch") == "aktiv": continue if t.get("schritt") in erste_takte: if t.get("phase") in ("handelt", "schliesst"): hinweise.append(f"Takt {t.get('schritt')}: Klick mitten im Takt -- die Zeile traegt schon " f"'{t.get('killswitch')}', aber noch die Phase '{t.get('phase')}'") continue pruefe(t.get("orders") == 0 and t.get("phase") not in ("handelt", "schliesst"), f"Takt {t.get('schritt')} handelt im Zustand {t.get('killswitch')}") print(f"Hinweise ({len(hinweise)}): {hinweise}") echte_klicks = [p for p in PLAENE[szenario] if p[0] != "NEUSTART"] pruefe(len(klickzeilen) == len(echte_klicks) and all(z.get("ausloeser") == "pascal" for z in klickzeilen), f"{len(klickzeilen)} Klick-Messungen statt {len(echte_klicks)} (ein Neustart ist kein Klick)") pruefe(all(isinstance(z.get("sekunden"), (int, float)) and z["sekunden"] <= 2.0 * tempo + 1.0 for z in klickzeilen), "eine Klick-Reaktion dauerte laenger als zwei Takte plus eine Sekunde") if szenario == "hand": erwartet = [("fortsetzen", "angehalten", "aktiv"), ("anhalten", "aktiv", "angehalten"), ("fortsetzen", "angehalten", "aktiv"), ("glattstellen", "aktiv", "gesperrt"), ("freigeben", "gesperrt", "aktiv")] pruefe([(k["was"], k.get("vorher"), k.get("nachher")) for k in klicks] == erwartet, "Uebergaenge anders als geplant") erster_handel = next((t for t in takte if t.get("position") != 0), None) angehalten = [t for t in takte if t.get("killswitch") == "angehalten" and erster_handel is not None and t.get("schritt", 0) > erster_handel.get("schritt", 0)] pruefe(bool(angehalten), "kein Takt im Zustand angehalten nach dem ersten Handel") pruefe(bool(angehalten) and len({t.get("position") for t in angehalten}) == 1 and angehalten[0].get("position") != 0, "Anhalten: Position blieb nicht stehen oder war null") gesperrt = [t for t in takte if t.get("killswitch") == "gesperrt"] pruefe(bool(gesperrt), "kein Takt im Zustand gesperrt") pruefe(bool(gesperrt) and all(t.get("position") == 0 for t in gesperrt[1:]), "gesperrt: Position nicht null ab dem zweiten Takt") if gesperrt: danach = [t for t in takte if t.get("schritt", 0) > gesperrt[-1].get("schritt", 0)] pruefe(any(t.get("killswitch") == "aktiv" and t.get("position") != 0 for t in danach), "nach dem Freigeben wurde nicht wieder gehandelt") pruefe(konto_des_dashboards.aufrufe.count("schliesse_alle") == 1, "Dashboard-Konto: schliesse_alle nicht genau einmal") pruefe(konto_des_dashboards.aufrufe.count("storniere_alle") == 2, "Dashboard-Konto: storniere_alle nicht genau zweimal") pruefe(ende.get("killswitch") == "aktiv", f"Killswitch am Ende {ende.get('killswitch')}") elif szenario == "neustart": erwartet = [("fortsetzen", "angehalten", "aktiv"), ("NEUSTART", "aktiv", "angehalten"), ("fortsetzen", "angehalten", "aktiv")] pruefe([(k["was"], k.get("vorher"), k.get("nachher")) for k in klicks] == erwartet, "Uebergaenge anders als geplant") erster_handel = next((t for t in takte if t.get("position") != 0), None) pause = [t for t in takte if t.get("killswitch") == "angehalten" and erster_handel is not None and t.get("schritt", 0) > erster_handel.get("schritt", 0)] pruefe(bool(pause), "kein Takt im Zustand angehalten nach dem ersten Handel") # Die Phase gilt ab dem zweiten Takt der Pause (siehe oben: ein Klick mitten im Takt), die Position ab dem ersten. pruefe(bool(pause) and all(t.get("position") == erster_handel.get("position") for t in pause) and all(t.get("phase") == "pausiert" for t in pause[1:]), "Neustart: Position blieb nicht stehen oder Phase nicht pausiert") if pause: danach = [t for t in takte if t.get("schritt", 0) > pause[-1].get("schritt", 0)] pruefe(any(t.get("killswitch") == "aktiv" and t.get("phase") == "handelt" for t in danach), "nach dem Fortsetzen wurde nicht wieder gehandelt") pausenmeldungen = [m for m in transport.aufrufe if "pausiert" in str(m["titel"])] pruefe(len(pausenmeldungen) == 1, f"{len(pausenmeldungen)} Pausenmeldung(en); erwartet genau eine zum Neustart") pruefe(all(any("neustart" in zeile for zeile in m["zeilen"]) for m in pausenmeldungen), "die Pausenmeldung nennt den Ausloeser neustart nicht") neustarts = [e for e in ereignisse if e.ausloeser == "neustart"] pruefe(len(neustarts) == 2, f"{len(neustarts)} Neustart-Ereignisse; erwartet eines beim Start und eines mitten am Tag") pruefe(ende.get("killswitch") == "aktiv", f"Killswitch am Ende {ende.get('killswitch')}") else: pruefe(bool(grenzereignisse) and all("tagesverlust" in str(z.get("text")) for z in grenzereignisse), "kein Grenzereignis zum Tagesverlust") vom_runner = [e for e in ereignisse if e.ausloeser == "paper-runner"] pruefe(bool(vom_runner) and all(e.stufe == "glattstellen" and e.erfolgreich for e in vom_runner), "kein erfolgreiches Glattstellen durch den Runner im Register") sturz_takt = next((t for t in takte if (ortszeit(t["letzte_kerze"]).hour, ortszeit(t["letzte_kerze"]).minute) >= STURZ_AB), None) sperr_takt = next((t for t in takte if t.get("killswitch") == "gesperrt"), None) # Hoechstens ein Takt Verzug: Der Kontowert wird im Takt vor der Entscheidung gelesen. pruefe(sturz_takt is not None and sperr_takt is not None and 0 <= sperr_takt.get("schritt", 0) - sturz_takt.get("schritt", 0) <= 1 and sperr_takt.get("position") == 0, "der Runner sperrt nicht spaetestens einen Takt nach der ersten gestuerzten Kerze, oder nicht glatt") if sperr_takt is not None: print(f"Sturz: erste gestuerzte Kerze in Takt {None if sturz_takt is None else sturz_takt.get('schritt')}, " f"gesperrt und glatt ab Takt {sperr_takt.get('schritt')}") danach = [t for t in takte if t.get("schritt", 0) >= sperr_takt.get("schritt", 0)] pruefe(all(t.get("position") == 0 for t in danach), "nach dem Sturz noch einmal eine Position") # Faellt das "Freigeben" mitten in einen Takt, kann genau dessen Zeile 'aktiv' tragen; der naechste Takt # prueft die Grenze und sperrt wieder. Mehr als ein solcher Takt, oder einer mit Order oder Position, ist # ein Befund. offen = [t for t in danach if t.get("killswitch") != "gesperrt"] pruefe(len(offen) <= 1 and all(t.get("orders") == 0 and t.get("position") == 0 for t in offen), f"nach dem Sturz enden {len(offen)} Takte nicht gesperrt, oder einer davon mit Order oder Position") grenze = danach[0]["startkapital"] * -config_grenze(code_baum) pruefe(all(t["kontowert"] - t["startkapital"] < grenze for t in danach), "Kontowert nach dem Sturz nicht unter der Grenze") spaete = [zeile for zeile, folgetakt in order_takte if folgetakt is not None and folgetakt.get("schritt", 0) > sperr_takt.get("schritt", 0)] pruefe(not spaete, f"{len(spaete)} Order(s) nach dem Sturz") grenzmeldungen = [m for m in transport.aufrufe if "Grenze verletzt" in str(m["titel"])] pruefe(len(grenzmeldungen) >= 2, f"{len(grenzmeldungen)} Grenzmeldung(en); erwartet eine beim Sturz und eine nach dem Freigeben") pruefe(all(any(zeile == "Killswitch: gesperrt" for zeile in m["zeilen"]) for m in grenzmeldungen), "eine Grenzmeldung nennt nicht 'Killswitch: gesperrt'") pruefe(len(vom_runner) >= 2, f"{len(vom_runner)} Glattstellen durch den Runner; erwartet eines beim Sturz und eines nach dem Freigeben") def config_grenze(code_baum: Path) -> float: """max_tagesverlust_anteil aus config/paper.toml des Code-Baums -- gelesen, nicht abgeschrieben.""" import tomllib daten = tomllib.loads((code_baum / "config" / "paper.toml").read_text(encoding="utf-8")) return float(daten["grenzen"]["max_tagesverlust_anteil"]) if __name__ == "__main__": try: main() except BaseException as ausnahme: # noqa: BLE001 -- ein Absturz ist ein Befund, kein Ergebnis BEFUNDE.append(f"Absturz: {ausnahme!r}") traceback.print_exc() finally: print(f"{len(BEFUNDE)} Befund(e): {BEFUNDE}") sys.exit(2 if BEFUNDE else 0)