diff --git a/.gitignore b/.gitignore index 355e1cb..15920ab 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,10 @@ !/.claude/workflows/ !/.vault-tools/ +# Ausnahmen: Trading-Werkzeuge und watermarks-remover bleiben privat +# (Pascal, 2026-10-15). Dieselbe Liste steht als AUSSCHLUESSE im Spiegel. +/.vault-tools/trading-plattform-pruefung/ +/.vault-tools/watermarks-remover/ # Niemals, egal wo # Laufzeitsperre des Auto-Push-Hooks. Sie entsteht bei jedem Lauf und diff --git a/.vault-tools/trading-plattform-pruefung/LIESMICH.md b/.vault-tools/trading-plattform-pruefung/LIESMICH.md deleted file mode 100644 index 5cbaa2a..0000000 --- a/.vault-tools/trading-plattform-pruefung/LIESMICH.md +++ /dev/null @@ -1,273 +0,0 @@ ---- -type: resource -status: active -title: "Trading Plattform: Prüfwerkzeuge des Koordinators fürs Dashboard" -summary: "Eigener Lauf mit echten Daten zum Dashboard der Trading Plattform — Vergleich der Endpunkte gegen Vollparse und pandas, zwei WebSocket-Verbindungen, Browser-Lauf und Wiederaufbau mit Playwright, gefahrlose Proben der zwei Skripte, Mutantenläufe. Entstanden 2026-09-17 (Teil 3b), erweitert 2026-09-18 (Teil 10: Anmeldung, Angriffsfälle, Beobachtung, Kerzenlatenz), bewusst nicht im Projekt-Repo." -tags: [vault-tools, trading-plattform] -created: 2026-09-17 -updated: 2026-10-12 ---- - -# Prüfwerkzeuge fürs Dashboard der Trading Plattform - -Koordinator-Werkzeuge, nicht fürs Repo. Sie starten **nie** den echten Broker: Das Probe-Backend läuft mit `BrokerAttrappe` und einem Wegwerf-Register unter `/tmp`, liest aber die echten Protokolle und den echten Katalog. Python-Teile laufen in WSL aus dem Projektordner, die Browser-Teile mit dem Windows-Python und Playwright (Chromium). - -| Datei | Was sie tut | Aufruf | -|---|---|---| -| `pruef_teil3b.py` | Liste und Einzelansicht der Läufe gegen einen eigenen Vollparse, Kerzen gegen eine eigene pandas-Rechnung; `--sabotage` verfälscht je einen Wert und muss mit Exit 2 enden (sieben Fälle) | `wsl -d Debian -- bash -s -- pruef_teil3b.py alles [--sabotage zahl] < lauf_pruef.sh` | -| `pruef_ws.py`, `lauf_ws_probe.sh`, `start_probe_backend.py`, `start_probe.sh` | zwei WebSocket-Verbindungen am Probe-Backend müssen dieselben `zeit`-Werte sehen | `wsl -d Debian -- bash -s < lauf_ws_probe.sh` | -| `browserlauf.py` | bei 375, 768, 1024 und 1440 px: Zeilen in „Läufe", zwei Einzelansichten, Minuten- und Tageschart gezeichnet, kein waagerechter Scroll, keine Konsolenmeldung; schreibt Screenshots — **ansehen** | Probe-Backend starten (`wsl -d Debian -- bash -s 8145 2.0 < start_probe.sh`, im Hintergrund), dann `python -X utf8 browserlauf.py http://localhost:8145 ` | -| `wiederaufbau.py` | Backend beenden und neu starten, die Kopfzeile muss „Backend getrennt" und wieder „Backend verbunden" zeigen, ohne Neuladen; `--ohne-neustart` muss rot enden | `python -X utf8 wiederaufbau.py` | -| `skripte_probe.sh`, `abkuerzung_probe.sh` | `--help`, Lauf ohne Schalter, unbekanntes Argument und Abkürzungen von `--ausfuehren` — **ausschließlich aus einem leeren Temp-Verzeichnis**; die Übung selbst wird nie gefahren | `wsl -d Debian -- bash -s < skripte_probe.sh` | -| `mutanten_py.py`, `mutanten_ts.py` | Mutanten zu Teil 3b auf einer Kopie mit angewendetem Plan, je mit Fehlerzeile | Pfad der Kopie als erstes Argument | -| `real_pytest.sh` | volle Suite im echten Baum, mit Zählung der „Task was destroyed"-Meldungen | `wsl -d Debian -- bash -s -- -q < real_pytest.sh` | - -## Teil 10 (2026-09-18): Paper-Runner, Anmeldung, Härtung - -| Datei | Was sie tut | Aufruf | -|---|---|---| -| `start_probe_zugang.py` | die echte App mit Attrappe, Wegwerf-Register und Wegwerf-Zugang (Benutzer `probe`, bekanntes Passwort, TOTP-Beispielgeheimnis der RFC-Literatur; beides in `zugang.json` der Ablage), auf Wunsch mit einer Paper-Statusdatei. **Seit 2026-09-21 bricht es ab, wenn im Arbeitsordner eine `config/secrets.toml` liegt** — `erzeuge_app` startete bis Paket S5 immer den echten Stille-Wächter, und eine Probe-Instanz im echten Projektordner hätte nach 180 s aufs Telefon geklingelt | aus einer **Kopie** des Projektordners (ohne Geheimnisdatei) in WSL: `PYTHONPATH=. python start_probe_zugang.py [mit-paperstatus]` | -| `angriffe.py` | 56 Fälle gegen Host-Positivliste, Origin, Sitzungszwang, Cookie-Attribute, Bremse, CSP und die anderen Header, WebSocket ohne Sitzung; Regressionsnachweis gegen das Backend vor Teil B: 45 rot | `python angriffe.py ` | -| `browserlauf_zugang.py` | Anmeldeformular ohne Sitzung, falsche und richtige Anmeldung (frischer TOTP-Code), Name und „Abmelden" in der Kopfzeile, Strom, Karte „Paper-Betrieb", alle Ansichten unter der CSP, Abmelden und Neuladen, 375 px; protokolliert jede Antwort ab 400 mit Phase und Pfad (`antworten-ab-400.json`); `--sabotage` muss rot enden; Screenshots — **ansehen** (der Lauf fand so das `GET /api/paper` vor dem Tor und die unbeschrifteten Felder) | `python -X utf8 browserlauf_zugang.py http://localhost: /zugang.json [--sabotage]` | -| `pruef_beobachtung_echt.py` | `baue_beobachtung` gegen `NautilusEnv._beobachtung` auf den echten IEX-Kerzen eines Tages, beide Seiten unabhängig gerechnet; Sabotage rot. Seit 2026-09-18 mit zweitem Teil: eine feste Aktionsfolge über alle fünf Stufen, Vergleich nach jedem Schritt bei gleichem Zustand (gemessen am 2026-09-17: 120 Beobachtungen bitgleich, davon 50 long, 50 short, Kontowert in allen ungleich dem Startkapital, Zähler in 60 größer null); `--mutant-vorzeichen` (Betrag statt Vorzeichen der Position) lässt genau die 50 Short-Beobachtungen rot fallen, während der erste Teil grün bleibt | `PYTHONPATH=. python pruef_beobachtung_echt.py JJJJ-MM-TT` aus dem Projektordner | -| `lies_paper_protokoll.py` | fasst ein Paper- oder Probe-Protokoll zusammen (Takte je Phase, Killswitch je Takt, frische Kerzen, erste Entscheidung, Orders, Schlussphase, Ende) | `python lies_paper_protokoll.py ` | -| `handelstag_auswertung.py` | wertet einen Paper-Handelstag aus: Takte je Phase, frische Kerzen, Spanne Taktzeile gegen Minutenende (Obergrenze für „Kerze abrufbar"), Orders, Ende; mit `--broker` lesend die Orders des Paper-Kontos (Annahme bis Füllung aus `created_at`/`filled_at` gegen `order_timeout_s`, dazu die Teilspannen bis `submitted_at` und von dort bis zur Füllung — seit dem ersten Handelstag, an dem der Broker eine Order 8,7 s zwischen Annahme und Weitergabe liegen ließ und `submitted_at` allein das verschwiegen hätte; ein Hinweis ab der halben Grenze; Vorzeichen offener Positionen), mit `--kerzen` die Kosten je Order gegen den Schluss der letzten abgeschlossenen IEX-Kerze in Basispunkten. Am 2026-09-18 an den zwei Protokollen der Nacht erprobt (Probe: Spanne ausgelassen, weil die Kerzen gespielt sind) | aus dem Projektordner: `python handelstag_auswertung.py [--broker] [--kerzen]` | -| `vorstart_pruefung.py` | liest vor dem Runner-Start: läuft schon ein Runner, Killswitch-Zustand im Register (nur Auskunft — der Start stellt nach der Neustartregel immer auf `angehalten`; ein Befund ist allein `gesperrt`), Paper-Konto glatt, Marktuhr des Brokers, eigene Uhr gegen den `Date`-Kopf des Brokers. Am 2026-09-18 fand der erste trockene Lauf einen Killswitch auf `aktiv` (Klick um 08:55 Uhr), den der Start überschreibt | aus dem Projektordner: `python vorstart_pruefung.py` | -| `runner_stand.py` | zeigt den Stand eines laufenden Paper-Runners aus seinem Protokoll: Zeilen je Art, Takte je Phase und Killswitch-Zustand, letzter Takt, Order- und Ende-Zeilen. Urteilt nicht und gibt keine Kontowerte aus. Am 2026-09-18 am Protokoll der Nacht erprobt (drei Zeilen: `kopf`, `ereignis`, `ende`); an einem Protokoll mit Takten noch nicht | in WSL: `python runner_stand.py []`, ohne Angabe das neueste `*-paper.jsonl` im Projektordner | -| `protokoll_vergleich.py` | vergleicht zwei JSONL-Protokolle der Messwerkzeuge Wert für Wert (Bitgleichheit nach einem Umbau); trennt `ABWEICHUNG`, `NUR NEU` (neuer Schlüssel, kein Befund ohne `--streng`) und `NUR ALT`. Erprobt am 2026-09-18: Selbstvergleich 5.190 Werte ohne Befund, `--sabotage` (ein ULP) rot, der Ein-Jahres-Lauf mit `OMP_NUM_THREADS=8` gegen den ohne 621 Abweichungen ab der dreizehnten Stelle | `python protokoll_vergleich.py [--ohne …] [--streng] [--sabotage]` | -| `probe_neu_einfrieren.py` | friert `tests/fixtures/tage_t0_t1_referenz.json` nur neu ein, wenn der Diff genau der vorhergesagte ist (neuer Schlüssel `fenster_ausgelassen_pool` in den Poolzeilen, geänderte Werte allein in der T1-Poolzeile und dort nur in den gelisteten Feldern); erprobt am 2026-09-18 an der Kopie mit angewendetem Plan B: 24 erlaubte Änderungen, 0 Befunde; `--sabotage` (ein ULP in einer T0-Zeile) rot, und `--schreiben` schrieb dann nichts | in WSL aus der Projektwurzel: `python probe_neu_einfrieren.py [--schreiben] [--sabotage]` | -| `ntfy_probe_server.py` | nimmt ntfy-Meldungen auf `127.0.0.1` entgegen und schreibt sie als JSONL mit (Titel, Priorität, Tags, Text; vom `Authorization`-Kopf nur Schema und Länge) — für den Lauf mit echten Daten zu Paket D ohne echtes Thema; `--status 403` lässt jede Zustellung scheitern | `python ntfy_probe_server.py [--status 200] [--verzoegerung 0.0]` | -| `handabruf_kerzen.py` | Alter der jüngsten AAPL-Minutenkerze per REST (`feed` iex oder sip) mit Urteil FRISCH/ALT — die Messung vor dem ersten Paper-Tag; nachts nur ein Funktionsnachweis. Gemessen 2026-09-18 nachts: iex antwortet in 0,5 s, sip mit 403 „subscription does not permit querying recent SIP data" | aus dem Projektordner: `TZ=Europe/Berlin python handabruf_kerzen.py [iex|sip]` | - -**Seit 2026-09-18 kennen `angriffe.py` und `browserlauf_zugang.py` https.** `angriffe.py` erwartet je nach Schema der Basis-URL `Secure` am Cookie und `Strict-Transport-Security` (https) oder beides nicht (http), dazu unter https keine Antwort 200 auf Klartext; `--ca ` nennt das Zertifikat, dem vertraut wird (geprüft, nicht abgeschaltet). `browserlauf_zugang.py --selbstsigniert` schaltet die Zertifikatsprüfung des Browsers ab. Regressionsnachweis vom selben Tag: gegen ein TLS-Backend mit einem Mutanten von `sicherheit.py` (ohne `Secure`, ohne HSTS) fielen genau die vier https-Fälle rot, Exit 2; Grundlinie über http 59 Fälle, über https 60. Läuft das Backend auf einem anderen Rechner, fährt man `angriffe.py` dort in einem Wegwerf-Container mit `--network host` und legt die drei Dateien der Ablage (`zugang.json`, `status.json`, `anmeldungen.jsonl`) per Verknüpfung in einen Ordner — das Werkzeug braucht Dateizugriff auf die Ablage des Backends. - -## Paket F (2026-09-20): Produktivbetrieb als Dienst - -| Datei | Was sie tut | Aufruf | -|---|---|---| -| `pause_echtlauf.py` | Tagesverlust-Schutz in der Pause auf den echten IEX-Kerzen eines Tages: rechnet aus den Rohkerzen vor, in welchem Takt die Grenze reißen muss (long und short, Grenze bei der Hälfte des größten Tagesverlusts), und hält das gegen das Protokoll der echten `PaperSitzung` im Zustand `angehalten`; dazu die Gegenprobe ohne Position. Einziger Netzzugriff: die Kerzen, lesend; Meldungen gehen an einen mitschreibenden Transport. Gemessen am 2026-09-17: long Takt 6, short Takt 173, je erwartet gleich gemessen | aus dem Projektordner in WSL: `python pause_echtlauf.py JJJJ-MM-TT` | -| `dienste_stand.sh` | nur lesend: Unit-Dateien, `is-enabled`, Zustand, Startzeiten und letzter Exit-Code von Dashboard-Dienst und Runner, Zeitgeber, `/api/gesund`, Start von systemd, die letzten Journalzeilen beider Dienste, `status.json` und `tagesstart.json` ohne Kontowerte. Das Journal geht ohne `sudo`, wenn der Benutzer in `adm` oder `systemd-journal` ist (hier: ja); sonst nimmt das Skript `sudo -S` mit `WSL_PW` aus der Umgebung | aus PowerShell: `wsl -d Debian --exec bash /mnt/d/Claude/.vault-tools/trading-plattform-pruefung/dienste_stand.sh` | -| `hole_kerzen.py` | holt die echten IEX-Minutenkerzen eines Handelstags lesend und legt sie als JSON ab — die Eingabe für `neustart_echtlauf.py`. Gemessen am 2026-09-20 für den 2026-09-17: 390 Kerzen, byte-gleich mit dem Abruf des Abschlussprüfers | aus dem Projektordner in WSL: `python hole_kerzen.py JJJJ-MM-TT ` | -| `neustart_echtlauf.py` | der Tagesverlust nach einem Neustart mitten am Tag: rechnet aus den Rohkerzen vor, bei welcher Kerze die echte Grenze (0,2 Prozent) gegen den Tagesstart reißt und bei welcher gegen den Neustartkurs, und fährt dann zwei echte `PaperSitzung`en nacheinander mit gemeinsamer `tagesstart.json`, dazu die Gegenprobe ohne Datei. Kein Netz. Gemessen am 2026-09-20 auf den Kerzen des 2026-09-17 und 2026-09-18: mit Datei Riss bei der vorgerechneten Kerze (5, 150, 19), ohne erst bei 18, 173, 42 | aus dem Projektordner in WSL: `python neustart_echtlauf.py ` | -| `ntfy_lesen.py` | liest über die Abfrage-Schnittstelle von ntfy zurück, was seit `--seit` unter dem Thema der Maschine ankam (Vorgabe `12h`; auch Berliner Ortszeit `JJJJ-MM-TTTHH:MM`) — Zeit in Berliner Zeit, Priorität, Titel und erste Zeile, darin jede Ziffernfolge als `#`. **Schickt nichts und gibt das Thema nie aus**, auch nicht in einer Fehlermeldung. Setzt `TRADING_GEHEIMNISSE` selbst, wenn die Variable fehlt. Exit 3 ohne Thema oder Geheimnisdatei, Exit 4 bei Weiterleitung oder Fehlerstatus (einer Weiterleitung folgt es nie, sonst ginge das Token mit). ntfy.sh hält Meldungen nur Stunden: Am 2026-10-08 um 14 Uhr war die Meldung von 22:36 am Vortag schon weg | aus dem Projektordner oder einem Arbeitsbaum in WSL: `python -B ntfy_lesen.py --seit 12h` | -| `wiederanlauf_probe.ps1` | beendet die Distribution hart (`wsl --terminate`) und misst, wann geplante Aufgabe, Distribution und Dashboard von selbst zurück sind. **Ein laufender Runner stirbt mit — nur außerhalb der Handelszeit, nur mit `-Ausfuehren`** (ohne den Schalter Exit 2, nichts getan). Während der Messung kein anderer `wsl.exe`-Aufruf, jeder Klient weckt die Distribution selbst. Gemessen am 2026-09-20: Rückkehr am nächsten Fünf-Minuten-Rasterpunkt, Dashboard 13 s danach | `powershell -File wiederanlauf_probe.ps1 -Ausfuehren` | - -## Paket H (2026-09-21): Kennzahlen nach Home Assistant - -| Datei | Was sie tut | Aufruf | -|---|---|---| -| `ha_einrichten.py` | richtet in Home Assistant die Gegenstelle für den Hausmelder ein: acht Helfer mit dem Präfix `trading_` und eine Automation mit Webhook-Auslöser (`local_only`, nur POST). Die Webhook-ID entsteht im Skript (64 Hex-Zeichen), wird nie ausgegeben und landet nur in `[homeassistant]` der `config/secrets.toml` des Trading-Projekts, mit `aktiv = false`. `pruefen` und `lesen` lesen nur (`lesen` zeigt zusätzlich die Zustände der Helfer), `probe` schickt eine Beispiel-Nutzlast und liest zurück, `entfernen` baut Helfer und Automation wieder ab. **Läuft unter Windows mit dem Python des Home-Assistant-Projekts** — dort liegen der Klient `src/ha_verbindung.py`, die Adresse der Instanz und der Name der Token-Variable; das Token bleibt auf der Windows-Seite und kommt nie auf den Trading-Rechner. Gemessen am 2026-09-20/21: eingerichtet, `probe` rückgelesen wie gesendet, und ein POST des echten `Hausmelder` aus WSL kam in den Helfern an | `\.venv\Scripts\python.exe -X utf8 ha_einrichten.py "" ""` | - -## Nacht vom 2026-09-20 auf den 2026-09-21: Generalprobe, Hausmelder, YubiKey, Härtung - -| Datei | Was sie tut | Aufruf | -|---|---|---| -| `generalprobe_echtlauf.py` | ruft die **echte** Funktion `_generalprobe` aus `scripts/paper.py` — dieselbe Verdrahtung wie im Betrieb — mit den echten IEX-Kerzen eines Tages (JSON aus `hole_kerzen.py`), einer Sicherungskopie des Registers (das echte wird nur lesend geöffnet), dem echten Registermodell, einem mitschreibenden „echten" Konto, das bei jedem schreibenden Aufruf wirft, einer Netzsperre auf `requests.Session.request` und einem Faden, der wie das Dashboard klickt (fortsetzen, anhalten, fortsetzen, glattstellen, freigeben). Urteilt über Exit-Code, Modus, Meldungsvorsatz, Netzversuche, Aufrufe am Konto, Position nach dem Glattstellen, Alter des Herzschlags, `tagesstart.json`. Gemessen am 2026-09-20/21 mit den Kerzen des 2026-09-18: bei 1 s je Marktminute 435 s, 389 Takte, drei Orders, 0 Befunde | in WSL: `python generalprobe_echtlauf.py [tempo]` | -| `hausmelder_echtlauf.py` | der echte `Hausmelder`-Faden gegen einen mitschreibenden HTTP-Server auf `127.0.0.1`: erste Nutzlast ohne Statusdatei, Änderung, Stille ohne Änderung, Lebenszeichen, Killswitch-Wechsel, HTTP 500 (Merker kippt nicht), 302 (nicht gefolgt), `veraltet`, Adresse in keinem Fehlschlag und keinem `repr`, `starte_hausmelder` über eine Geheimnisdatei. Mit `--echt ` zusätzlich **ein** POST an die dort eingetragene Adresse; die Adresse wird nie ausgegeben. Zurücklesen mit `ha_einrichten.py lesen` | in WSL: `python hausmelder_echtlauf.py [--echt ]` | -| `ha_aktiv_setzen.py` | setzt `[homeassistant] aktiv` in einer Geheimnisdatei auf `true` oder `false` — genau eine Zeile, mit Probe vor dem Schreiben, dass sich nur dieser Wert ändert; gibt nur Abschnittsnamen und die Länge der Adresse aus | `python ha_aktiv_setzen.py ` | -| `start_probe_w.py` | Probe-Backend wie `start_probe_zugang.py`, dazu die Konfiguration für Sicherheitsschlüssel (RP-ID `localhost`, Origin `http://localhost:`) und wahlweise ein nur gelesener Marktkatalog. **Bricht ab, wenn im Arbeitsordner eine `config/secrets.toml` liegt** — Probe-Instanzen starten nur aus einer Kopie oder einem Arbeitsbaum ohne Geheimnisse, sonst sprächen sie mit dem echten ntfy und dem echten Home Assistant | in WSL aus der Kopie: `python start_probe_w.py []` | -| `browser_w.py` | Browserlauf zum YubiKey-Weg: Playwright/Chromium mit einem **virtuellen Authenticator** über das DevTools-Protokoll (`WebAuthn.addVirtualAuthenticator`) gegen die Probe-Instanz unter `http://localhost:` — Anmelden mit Code, Registrieren in „Zugang", Anmelden mit Passwort und Schlüssel (der Zähler des Authenticators muss steigen, sonst wurde er nie gefragt), falsches Passwort, Code-Weg danach, Entfernen, Gegenprobe unter `127.0.0.1` (409), Schlüsseldatei und Anmeldeprotokoll ohne Passwort, keine CSP- oder Permissions-Policy-Meldung. Gemessen am 2026-09-21: 17 von 17, auch gegen eine gehärtete Systemeinheit | unter Windows: `python -X utf8 browser_w.py ` | -| `latenz_auswertung.py` | rechnet Reaktionszeiten aus dem, was ein Lauf ohnehin hinterlässt — nur lesend, Register mit `mode=ro`: Klick → Runner reagiert (Ereignis in `killswitch_ereignisse` mit Auslöser `pascal` gegen die erste Taktzeile mit dem neuen Zustand; obere Schranke, weil die Taktzeile am Ende des Takts entsteht), Kerze → Taktende (nur im Modus `paper`) und den Taktabstand als Rahmen. Für Tage, an denen der Runner seine Latenzen noch nicht selbst misst. Gibt nie einen Kontowert aus | in WSL aus dem Projektordner: `python latenz_auswertung.py []` | -| `bremse_echtlauf.py` | Misst das Wachstum des Anmeldeprotokolls im gesperrten Zustand gegen eine laufende Probe-Instanz: fünf Fehlversuche, dann je 5 s Dauerfeuer auf `beginn`, `abschluss` und den Weg mit Code, gezählt in Zeilen und Bytes; zweiter Durchgang mit dem Abschluss als erster gesperrter Anfrage; dazu der 409 des Abschlusses unter `127.0.0.1`. Anlass: Der Prüfer maß am 2026-09-21 im Schlüsselweg 22 474 B/s, weil die Protokollbremse nur im Weg mit Code stand. Nach Task 229 gemessen: 2721 Anfragen in 5 s → eine Zeile | in WSL: `python bremse_echtlauf.py ` | -| `s5_echtlauf.py` | Gegenmessung der Dashboard-Härtung gegen eine laufende Probe-Instanz: 422 ohne die Eingabe, 512 MiB Rumpf mit und ohne `content-length` samt Spitzenspeicher des Servers aus `/proc`, Latenz von `POST /api/killswitch/anhalten` unter 40 parallelen teuren Kerzenabrufen, überlanger Grund, Sitzungswiderruf nach Passwortwechsel. Gemessen am 2026-09-21: 413 in 0,19 s ohne Speicherzuwachs (vorher 1,5 GiB), Killswitch höchstens 0,51 s (vorher 118 s) | in WSL: `python s5_echtlauf.py ` | -| `s1_alpaca_lesen.py` | liest mit dem Alpaca-Klienten eines Code-Baums die echten Antworten des Paper-Kontos — **nur lesend, ohne einen Betrag auszugeben** (Feldnamen, Typen, Statusverteilung der Orderhistorie, Felder mit `None`). Für den Vergleich alter gegen neuer Parser | in WSL: `python s1_alpaca_lesen.py ` | - -## Killswitch-Simulation (2026-09-21) - -| Datei | Was sie tut | Aufruf | -|---|---|---| -| `killswitch_simulation.py` | der Killswitch im Zeitraffer, **ohne Netz, ohne das echte Konto und ohne das echte Register** — die echte Funktion `_generalprobe` mit den Kerzen eines Tages (JSON aus `hole_kerzen.py`), verdrahtet wie `generalprobe_echtlauf.py`. Drei Szenarien: `hand` (fortsetzen, anhalten, fortsetzen, glattstellen, freigeben — gehandelt wird nur im Zustand `aktiv`, beim Anhalten bleibt die Position stehen, nach dem Glattstellen ist sie null und der Runner gesperrt), `sturz` (alle Kerzen ab 11:00 Uhr New Yorker Zeit zehn Prozent tiefer — der Runner reißt die Tagesverlustgrenze selbst, stellt glatt, sperrt, meldet „Grenze verletzt", und sperrt nach einem „Freigeben" im selben Takt wieder, ohne neue Order) und `neustart` (das Dashboard startet mitten am Tag neu — Pause mit stehender Position, Pausenmeldung mit dem Auslöser `neustart`, und die Latenzanzeige zählt den Neustart nicht als Klick). Der Kopfkommentar nennt je Prüfung die Herkunft der Erwartung und die Grenzen: Gefüllt wird gespielt, der Klick geht nicht über HTTP, der Watchdog wird nicht gezielt ausgelöst. **Anlass:** Die Killswitch-Übung des Projekts stellt echte Orders und schreibt ins gemeinsame Register — sie darf nicht laufen, solange der Paper-Runner läuft. Gemessen am 2026-09-21 gegen den Betriebscode (0.20.0) mit den Kerzen des 2026-09-18 bei 1 s je Marktminute: je Szenario 431 s, Exit 0, 389 Takte, kein Befund und kein Hinweis; Reaktion des Runners auf einen Klick 0,85 bis 0,99 s (neun Messungen) | in WSL: `python killswitch_simulation.py [tempo]` | -| `killswitch_simulation_mutanten.py` | der Regressionsnachweis dazu: kopiert den Code-Baum, fährt zuerst die Grundlinie, macht die Kopie dann an sechs Stellen einzeln kaputt (Runner übergeht den Killswitch; Glattstellen schließt nicht; Tagesverlustgrenze greift nicht; jeder Auslöser zählt als Klick; keine Pausenmeldung beim Neustart; Tagesverlust hält nur an) und erwartet je Mutant Exit 2. Ein Muster, das nicht genau einmal trifft, ist ein Befund des Treibers. In zwei Teilen, je unter zehn Minuten. Gemessen am 2026-09-21 gegen den Betriebscode: Grundlinie und Gegenprobe in allen drei Szenarien ohne Befund, alle sechs Mutanten gesehen — drei davon an genau einer Prüfung (die Position nach dem Glattstellen, die Zahl der Klick-Messungen, die fehlende Pausenmeldung). **Der erste Lauf der Grundlinie fand dabei einen Fehler der Prüfung, nicht des Runners:** Im Schnelltempo fiel ein Klick mitten in einen Takt; dessen Taktzeile trug schon `gesperrt`, aber noch die Phase `handelt`, ohne Entscheidung und ohne Order. Seitdem ist alles ab dem zweiten Takt eines nicht aktiven Abschnitts verbindlich, der erste wird als Hinweis gedruckt | in WSL: `python killswitch_simulation_mutanten.py <1\|2>` | - -## Red Button (2026-09-22, Paket R) - -| Datei | Was sie tut | Aufruf | -|---|---|---| -| `browser_r.py` | Browserlauf zum Red Button mit virtuellem Sicherheitsschlüssel gegen eine Probe-Instanz (`start_probe_w.py` aus einer Kopie ohne Geheimnisdatei, mit gebautem `dashboard/dist`): anmelden mit Code, Schlüssel registrieren, unter `127.0.0.1` den Dialog öffnen (Hinweis „nur unter http://localhost:…", kein Wortfeld, gesperrter Knopf, kein POST), unter `localhost` den Red Button drücken, Grund eintragen, Stick (virtuell) berühren — beginn und Abschluss 200, Zähler des Authenticators steigt, genau ein Registerereignis `glattstellen` mit Auslöser `pascal`, Kopfzeile gesperrt; Screenshots. Importiert `totp`, `schritt` und `melde_mit_code_an` aus `browser_w.py`. **Reihenfolge ist Absicht:** erst `127.0.0.1`, solange der Killswitch noch bedienbar ist — nach dem Notaus ist er gesperrt. Gemessen am 2026-09-22: 15 von 16, der eine Befund war ein zu strenger Check des Skripts (berichtigt). Nicht gemessen: eine abgelehnte Signatur (der virtuelle Authenticator signiert immer gültig), das Wort ohne Schlüssel, die Protokollbremse — das deckt `tests/test_dashboard_red_button.py` | unter Windows: `python -X utf8 browser_r.py ` | - -Das Einrichten selbst (`deploy/dienste-einrichten.sh` unter `sudo -S`, `deploy/windows/wsl-wachhalten.ps1 -Aktion einrichten`) und das Eintragen des ntfy-Themas liefen aus dem Scratchpad der Sitzung; die Befehle stehen in der Administrator-Anleitung des Projekts, Abschnitt „Produktivbetrieb als Dienst". - -Die Compose-Probe (Bauen aus dem echten Baum mit verankerten Ausschlüssen, Start, Healthcheck, Angriffsfälle, Runner-Probe im Container, Aufräumen) lag als `compose_probe_echt.sh` im Scratchpad der Sitzung und braucht `sudo -S` mit dem WSL-Passwort aus `$WSL_PW`; sie ist deshalb nicht hier abgelegt. Muster: `tar --exclude=./data` — ohne Anker fällt auch `trading/data` weg. - -Vor dem Browser-Lauf `npm run build` im Projekt, sonst dient das Backend eine alte Oberfläche aus. Die Datensatz- und Dateinamen in `browserlauf.py` und `pruef_teil3b.py` sind die des Bestands vom 2026-09-17; nach einem neuen Katalog oder neuen Protokollen nachziehen. Die Statustexte in `wiederaufbau.py` sind aus `Kopfzeile.tsx` abgeschrieben. - -Die generischen Planwerkzeuge liegen unter `.vault-tools/plan-kopie/`. - -## Geheimnisdatei seit Paket E2 (2026-09-23) - -Die Werkzeuge nennen `config/secrets.toml` nicht mehr fest. Wer eine Projektklasse ruft, gibt `None` weiter (`from_secrets(None, …)`, `secrets_pfad=None`, `lies_ntfy_geheimnisse()`); wer die Datei selbst liest (`handabruf_kerzen.py`, `handelstag_auswertung.py`, `vorstart_pruefung.py`, `pruef_beobachtung_echt.py`), holt den Pfad mit `trading.geheimnisse.pfad()` und nennt im Fehlerfall `geheimnisse.beschreibung()`; `ha_einrichten.py` löst gegen den übergebenen Projektordner auf. Der Auflöser nimmt das Argument, dann `TRADING_GEHEIMNISSE`, dann `$CREDENTIALS_DIRECTORY/secrets.toml`, sonst `config/secrets.toml` relativ zum Arbeitsverzeichnis — **nach dem Umzug der Datei ins Linux-Dateisystem steht die Variable nur in einer Login-Shell** (`~/.profile`). Ein Werkzeug aus einer Skriptdatei über `wsl --exec bash` sieht sie nicht und meldet „fehlt": dann `export TRADING_GEHEIMNISSE=/home/claude/.config/trading-plattform/secrets.toml` im Skript setzen. `start_probe_w.py` und `start_probe_zugang.py` nehmen beide Variablen absichtlich aus ihrer Umgebung, damit eine Probe nie die echte Datei liest; `ha_aktiv_setzen.py` bekommt den Pfad als Argument. - -## Paket M (2026-09-24): zehn Aktien in einem Runner - -| Datei | Was sie tut | Aufruf | -|---|---|---| -| `handelstag_auswertung.py` | seit Paket M mit mehreren Instrumenten: Instrumente aus dem Protokollkopf; je Instrument Entscheidungen, Orders und die aus der Folge erwartete Zahl (Stufen, Versatz, Entscheidungen, Schlussbein, ohne geteilte Beine), erreichte Positionen und Takte mit frischer Kerze; groesste Zahl von Orders in einem Takt, Ereignisse je Art, Positionen je Instrument am Ende, BEFUND fuer jede nicht gefuellte Orderzeile; das Taktfeld orders gilt als Orders dieses Takts; `--broker` und `--kerzen` fragen alle Symbole des Kopfs, jede Order gegen die Kerze ihres Symbols | wie bisher: `python handelstag_auswertung.py [--broker] [--kerzen]` aus dem Projektordner | -| `m_probeaufruf.py` | lesender Probeaufruf vor dem Einspielen: `GET /v2/assets` roh (Schluessel und Typen) und ueber `AlpacaBroker.anlage`, `trades/latest` mit allen Symbolen roh und ueber `letzte_kurse`, Kerzen gebuendelt gegen einzeln, Seitenverhalten mit kleiner Seite; Exit 0 nur, wenn alles wie angenommen ist und keine Aktie die Startpruefung einer Folge mit Short verfehlt | aus einem Arbeitsbaum mit Paket M in WSL: `python m_probeaufruf.py ...` | - -## Betrieb ohne täglichen Klick (2026-09-24): Abendkontrolle und Rückweg - -Pascal am 2026-09-24: eine dauerhafte Abendkontrolle, die Neustarts übersteht und keine Updates von Claude Code oder Desktop blockiert, und zehn Aktien bis Mittwoch 2026-09-30. Beides läuft als Windows-Aufgabe mit `pythonw.exe`, ohne Claude-Prozess. - -| Datei | Was sie tut | Aufruf | -|---|---|---| -| `abendkontrolle_wsl.py` | liest einen Handelstag und urteilt im Code: Handelstag laut `GET /v2/calendar`, Protokoll des scharfen Runners, letzte Zeile „Ende: …, Exit n" im Journal (jeder Exit ungleich 0 ist ein Befund), BEFUND-Zeilen von `handelstag_auswertung.py --broker --kerzen`, offene Positionen beim Broker, mindestens ein Takt `aktiv`, Tagesbericht, Dashboard und Zeitgeber, Killswitch nicht `gesperrt`; bei Befund eine Meldung mit hoher Priorität über den Melder des Projekts. Gibt eine JSON-Zeile aus, nie Beträge | in WSL: `python abendkontrolle_wsl.py --tag JJJJ-MM-TT [--ohne-meldung]`; nur eine Meldung: `--meldung TITEL TEXT` | -| `abendkontrolle.py` | Windows-Seite: prüft jeden Werktag seit 2026-09-24 ohne Ergebnisdatei (höchstens sieben Tage zurück, heute erst ab 22:30), legt das Ergebnis unter `data/paper/abendkontrollen/.json` im Projekt ab und schreibt eine Zeile in `Inbox/.md` unter „Agentenprotokoll", bei Befund mit `#needs-review` | `python abendkontrolle.py [--tag JJJJ-MM-TT]` | -| `abendkontrolle_einrichten.ps1` | Aufgabe „Trading Plattform - Abendkontrolle": werktags 22:45 und fünf Minuten nach jeder Anmeldung, nur angemeldet, verpasste Läufe beim nächsten Start | `powershell -NoProfile -File abendkontrolle_einrichten.ps1 [-Aktion entfernen]` | -| `abendkontrolle_angriffe.py`, `abendkontrolle_ersatz_auswertung.py` | 44 Fälle in WSL (Kalender, Protokoll, Ende je Lauf samt mittags abgebrochenem und abendlichem Lauf, Journal, Auswertung, Broker, Maske, Bericht, Dienste, Killswitch, getrennte Fragen, Meldeweg gegen `ntfy_probe_server.py` und gegen einen toten Port, Bedienfehler; Reihenfolge an zwei Positionen) und 20 unter Windows (Notiz, Überschrift mit Zusatz, CRLF, kein UTF-8, Ergebnisform, Abbruch wird wiederholt, keine Nachbardatei, fällige Tage), ohne Netz. Am 2026-09-24 nach der unabhängigen Prüfung: 0 Abweichungen | `python abendkontrolle_angriffe.py` einmal in WSL (Venv), einmal unter Windows | -| `rueckweg_aapl.py`, `rueckweg_aapl_einrichten.ps1` | einmal: `config/paper-aapl-register.toml` wird `config/paper.toml`, lokal committet (nicht gepusht) — nur nach 2026-09-30 22:05, bei stehendem Runner, sauberem Projektordner mit `master`, wenn dort noch die Konfiguration mit `quelle = "folge"` liegt und beide Dateien ausser `[agent]`, den Instrumenten und dem Tagesverlust dieselben Schluessel und Werte tragen, in beide Richtungen verglichen (sonst schaltete der Tausch etwa ein abgeschaltetes `[auto_fortsetzen]` still wieder ein); ein halb erledigter Tausch wird zu Ende committet. Sonst Befund in der Notiz und eine Meldung. Aufgabe „Trading Plattform - Rueckweg AAPL" am 2026-09-30 22:50, zweiter Versuch 2026-10-01 22:10 | `python rueckweg_aapl.py`; Einrichten wie oben | -| `rueckweg_aapl_angriffe.py` | 22 Fälle auf einem Wegwerf-Git-Repo (dazu halb getauscht, Abschnitt nur in `paper.toml`, Automatik nur dort abgeschaltet, Abschnitt nur dort gelöscht, anderer Wert, anderer Zweig); am 2026-09-24 0 Abweichungen | `python rueckweg_aapl_angriffe.py` unter Windows | -| `abendkontrolle_mutanten.py` | baut je einen Fehler in eine Wegwerfkopie und verlangt, dass der Angriffstreiber rot wird: 16 Mutanten von `abendkontrolle_wsl.py` (in WSL), 9 von `abendkontrolle.py` und 12 von `rueckweg_aapl.py` (unter Windows). Am 2026-09-24: 37 von 37 gefangen | `python abendkontrolle_mutanten.py` einmal in WSL, einmal unter Windows | - -## Einspielen (2026-09-24): Werkzeuge aus dem Scratchpad hierher - -| Datei | Was sie tut | Aufruf | -|---|---|---| -| `dashboard_neustart.sh` | `sudo systemctl restart trading-dashboard.service` nur bei stehendem Runner, danach Tor 6b (Journal der letzten Minute ohne `PermissionError`/`Traceback`, sonst Exit 27), `/api/gesund` und die Oberfläche. Pascals dauerhafte Freigabe gilt für genau diesen Neustart außerhalb der Handelszeit | mit `WSL_PW` per WSLENV: `wsl -d Debian -u claude -- bash …/dashboard_neustart.sh` | -| `geheimnis_scan.py` | vor jedem Push: Werte ab 12 Zeichen aus Geheimnis- und Zugangsdatei gegen `git log -p ..`, nur Schlüsselnamen und Trefferzahlen; dazu, ob eine der drei Geheimnisdateien verfolgt wird. Regressionsnachweis mit `SCAN_PROBE=` (am 2026-09-24: 66 Treffer für einen bekannten Text, 0 für die echten Werte) | in WSL: `python geheimnis_scan.py [ …]` | -| `scan_push.sh` | Hülle um `geheimnis_scan.py` für den Projektordner: zählt die Commits im Bereich, fährt erst die Probe (`SCAN_PROBE_TEXT`, Vorgabe „Nachholtage melden“, ab 12 Zeichen) und dann den Scan. Nötig, weil aus der Git-Bash `$VAR` leer in WSL ankommt und der Scan dann „0 Zeichen Verlauf, 0 Treffer“ meldet (2026-09-25). Arbeitsbäume taugen nicht als ``: ihr `.git` zeigt auf einen Windows-Pfad | aus der PowerShell: `wsl -d Debian -- bash /mnt/d/Claude/.vault-tools/trading-plattform-pruefung/scan_push.sh ` | - -## IBKR-Zugang (2026-09-24): Paper-Konto über das Gateway im Container - -Pascal am 2026-09-24: Zugang zum IBKR-Paper-Konto vorbereiten, Gateway als Container in WSL, erster Test nach 22:00. Nichts davon handelt. - -| Datei | Was sie tut | Aufruf | -|---|---|---| -| `ibkr_zugang_eintragen.sh` | Pascal trägt Paper-Benutzername, Passwort (ohne Anzeige, zweimal) und Kontonummer ein; Ablage `/home/claude/.config/trading-plattform/ibkr/` (0700/0600); ausgegeben werden nur Längen. Mit erfundenen Werten in einem Wegwerf-Ordner geprüft (`IBKR_ZIEL`) | `wsl -d Debian -u claude -- bash …/ibkr_zugang_eintragen.sh` | -| `ibkr_gateway_test.sh` | Gateway-Container `ghcr.io/gnzsnz/ib-gateway` (auf eine Prüfsumme festgelegt), Paper, `READ_ONLY_API=yes`, Port nur auf `127.0.0.1:4004`; Schritte `laden`, `pruefen`, `starten`, `log` (Benutzername maskiert), `probe`, `stoppen`; verweigert zwischen 15 und 22 Uhr Berlin | mit `WSL_PW` per WSLENV: `wsl -d Debian -u claude -- bash …/ibkr_gateway_test.sh ` | -| `ibkr_probe.py` | lesende Probe über `nautilus-ibapi` im eigenen Venv `~/venvs/ibkr-probe`: Verbindung, Konto (nur Anfang und Länge), Kontoart, Währung, Positionen (Zahl), verzögerter AAPL-Kurs (nur die Kursart), Fehlercodes; nie ein Betrag. Ohne Gateway geprüft: „keine Verbindung", Code 502, Exit 3 | `~/venvs/ibkr-probe/bin/python …/ibkr_probe.py [--port 4004]` | - -## Paket AF (2026-09-24): Fortsetzen zum Tagesstart ohne Klick - -| Datei | Was sie tut | Aufruf | -|---|---|---| -| `af_gegenfall.py` | faehrt die echte PaperSitzung eines Arbeitsbaums mit AF und der ausgelieferten `config/paper.toml` gegen einen ProbeBroker, Kerzen aus einer Datei von `hole_kerzen.py` fuer jede Aktie, Wegwerf-Register mit der Neustartregel, ohne Netz; der Fall `restposition` (10 Stueck in der letzten Aktie) und der Fall `mitten` (Start 60 Marktminuten nach der Oeffnung) muessen `nicht_fortgesetzt` mit dem Grund in Protokoll und Meldung zeigen, der Fall `flach` ist die Gegenprobe mit `fortgesetzt`, Ausloeser `automatik` und Orders; Exit 0 nur ohne BEFUND | aus dem Arbeitsbaum in WSL: `python af_gegenfall.py `, Fall `restposition`, `mitten` oder `flach`; die Kerzen vorher einmal lesend mit `hole_kerzen.py ` | -| `af_registerprobe.py` | fragt die echte Funktion `pruefe_register` (Paket AF) gegen `data/registry.db`, nur lesend (`mode=ro`; die Klasse `Registry` fuehrte beim Oeffnen das Schema aus und schriebe); druckt Zustand, die vier juengsten Killswitch-Ereignisse (Stufe, Ausloeser, Zeit) und den Befund; Exit 0 erfuellt, 1 nicht erfuellt, 3 Register fehlt. Belegt am 2026-09-24 an drei gespielten Registern (bereit / Pascals Anhalten ohne Fortsetzen / aktiv) und an einer Kopie des echten; die mtime des echten blieb gleich. Braucht den Code mit AF im genannten Ordner | in WSL im Projektordner nach dem Dashboard-Neustart: `~/venvs/trading/bin/python af_registerprobe.py .` | - -Was die uebrigen Werkzeuge mit AF sehen: `handelstag_auswertung.py` braucht keine Aenderung, es zaehlt das Ereignis `auto_fortsetzen` unter `ereignisse je art` und druckt seinen Text. `abendkontrolle_wsl.py` liest Zustand, Seit und Ausloeser des Killswitch und berichtet jede Zeile `auto_fortsetzen` als Hinweis (die ersten 200 Zeichen des Feldes text, das mit dem Ergebnis beginnt). `neustart_echtlauf.py` setzt `angehalten`/`neustart` ohne Ereignis, `pause_echtlauf.py` einen Ausloeser `echtlauf`: Die Sitzung setzt dort nicht fort, schreibt aber je Lauf eine Zeile `auto_fortsetzen` und in "Paper-Runner wartet" eine Zeile mehr. `generalprobe_echtlauf.py` und `killswitch_simulation.py` laden `config/paper.toml` und klicken nach Plan; gegen einen Baum mit AF setzt die Sitzung dort mit dem Schalter selbst fort, bevor der geplante Klick kommt. Wer diese vier fuer einen Vergleich mit Paket M faehrt, setzt vorher `auto_fortsetzen=False` in ihr `replace(lade_paper_config(...), ...)`; `neustart_echtlauf.py` und `pause_echtlauf.py` rufen `lade_paper_config` ohne `replace` auf — dort in der Konfiguration ihrer Kopie `[auto_fortsetzen] aktiv = false` setzen. - -## Paket Z2 (2026-09-26): Totmann in Home Assistant - -| Datei | Was sie tut | Aufruf | -|---|---|---| -| `ha_totmann.py` | richtet in Home Assistant eine Automation ein, die im Handelsfenster (aus dem Kalender von Alpaca, 400 Tage, als Epoch-Sekunden in der Bedingung) meldet, wenn `input_datetime.trading_gesendet` aelter als 15 Minuten ist, einmal je Stille und Handelstag, ueber einen App-Dienst aus `--app-dienst` und ueber `rest_command.trading_totmann_ntfy`; Adresse mit Thema und Zugriff stehen nur in `/config/secrets.yaml` von Home Assistant, der Block `rest_command:` mit `!secret` in `/config/configuration.yaml`, beide zwischen Marken und ueber die File-editor-API geschrieben. `pruefen` laesst Home Assistant die Bedingung mit acht gestellten Lagen per `render_template` rechnen, `trocken` rechnet dieselben Lagen lokal, ohne Netz | unter Windows mit dem Python des HA-Projekts: `ha_totmann.py pruefen `, dann `einrichten ... --app-dienst notify.` (Exit 4: Home Assistant neu starten, dann noch einmal), `probe ... --app-dienst notify.` (zwei echte Meldungen), Rueckweg `entfernen ...`; `ha_totmann.py trocken` ohne Ordner | -| `ha_totmann_wsl.py` | WSL-Teil von `ha_totmann.py`: liest in WSL aus der Geheimnisdatei der Plattform Thema, Token und die Alpaca-Schluessel, fragt den Kalender von Alpaca und gibt genau eine JSON-Zeile auf stdout; `ha_totmann.py einrichten` startet ihn ueber `wsl.exe --exec` und liest die Zeile nur als Pipe. Die Geheimnisdatei verlaesst WSL nicht | nur ueber `ha_totmann.py einrichten`; nie von Hand, sonst stuende das Thema auf der Konsole | - -Was es nicht tut: Es aendert nichts an der Automation der Kennzahlen aus `ha_einrichten.py` und nichts an deren Helfern; es startet Home Assistant nie selbst neu. Der Kalender in der Bedingung laeuft nach 400 Tagen aus, danach schweigt der Totmann; der Alias der Automation nennt den letzten Tag, `pruefen` zeigt ihn, `einrichten` verlaengert. - -## Paket IB2 (2026-09-25): Abendkontrolle der IBKR-Instanz - -Seit Paket IB2 prüft die Abendkontrolle zwei Teile je Tag, nacheinander: den Alpaca-Runner wie bisher und die IBKR-Instanz im Betriebsordner `/mnt/d/Claude/.arbeitsbaeume/trading-ibkr-betrieb`. Der IBKR-Teil hat eine eigene Ergebnisdatei (`data/paper/abendkontrollen/-ibkr.json` im Projekt), eine eigene Zeile „Abendkontrolle IBKR (automatisch)“ in derselben Tagesnotiz, eigenes Nachholen ab `IBKR_ERSTER_TAG` in `abendkontrolle.py` und bei Befund eine eigene Meldung mit hoher Priorität, Titel „IBKR Abendkontrolle : n Befund(e)“. Ist der Zeitgeber `trading-paper-ibkr.timer` nicht `active` und gibt es kein Protokoll des Tages (No-Go-Tag), steht nur „IBKR-Instanz heute nicht gestartet (Zeitgeber aus).“ da, ohne Befund. - -| Datei | Was sich ändert | Aufruf | -|---|---|---| -| `abendkontrolle_wsl.py` | `--teil ibkr`: Kalender wie beim Alpaca-Teil; Protokolle `T*-paper.jsonl` im Betriebsordner (Ende je Lauf, mindestens ein Takt `aktiv`, Schlussposition jedes Laufs aus seiner Zeile `ende` unabhängig vom Journal); die Instanzmarke `data/paper/broker.txt` muss `ibkr` sagen; Register der Instanz nur lesend (`mode=ro`), `gesperrt` ist ein Befund mit Stufe und Auslöser des jüngsten Ereignisses; Journal von `trading-paper-ibkr.service` (Ende, Exit, `Failed with result`, `Traceback`, „Nichts wird gebaut“); werktags außerhalb 09:00 bis 16:15 New Yorker Zeit und am Wochenende `scripts/ibkr_pruefen.py lesen` im Betriebsordner mit `~/venvs/trading-ibkr` (Client-ID 12, lesend) — Positionen und offene Orders müssen 0 sein; im Handelsfenster nur ein Hinweis. Aus der Ausgabe von `ibkr_pruefen.py` gehen nur die Zahlen und die Zeilen `BEFUND` und „Voraussetzung fehlt“ weiter, Kontonummern als `[konto]`, dann durch `maskiere()` | in WSL: `python abendkontrolle_wsl.py --teil ibkr --tag JJJJ-MM-TT [--ohne-meldung]`; für die Angriffsfälle dazu `--ibkr-ordner`, `--ibkr-konto` (JSON mit `exit` und `text`), `--jetzt` (ISO mit Versatz), `--kalender`, `--journal`, `--dienste` | -| `abendkontrolle.py` | fährt ohne `--teil` beide Teile nacheinander, erst `alpaca`, dann `ibkr`; `--teil alpaca` oder `--teil ibkr` nur einen; mit `--ohne-wsl` ohne `--teil` nur `alpaca`. Ein Ergebnis, dessen `teil` nicht zum angeforderten passt, gilt als gescheitert. Die Befund-Zeile beider Teile baut dieselbe Funktion `_zeile` | `python abendkontrolle.py [--tag JJJJ-MM-TT] [--teil ibkr]` | -| `abendkontrolle_angriffe.py` | dazu IBKR-Fälle in WSL (Grundlinie, Zeitgeber aus und an, von Hand gelaufen, Kalender, Marke, Register, Ende je Lauf, Handel, Restposition im Protokoll bei sauberem Journal, Journal, Konto am Gateway samt Maske der Kontonummer, Handelsfenster, Ordner fehlt, Bedienfehler, Meldeweg gegen `ntfy_probe_server.py` mit Titel „IBKR …“ und Priorität `high`) und unter Windows (eigene Zeile und Datei, Befund, No-Go-Tag, kein Handelstag, Abbruch, gescheitert, Ergebnis eines anderen Teils, `--ohne-wsl` nur Alpaca, beide Teile in einer Notiz, fällige Tage relativ zu `IBKR_ERSTER_TAG`), ohne Netz | wie oben | -| `abendkontrolle_mutanten.py` | dazu Mutanten der IBKR-Teile beider Dateien, auch für Schlussposition, Titelpräfix, Meldeweiche und die Prüfung des Teils; die Anker „keine Ergebnisdatei“ und „Wochenende mit“ folgen dem Code aus IB2 | wie oben | - -Die Zahlen der Fälle und Mutanten im Abschnitt vom 2026-09-24 gelten für den Stand vor IB2; wie viele es jetzt sind, sagt die Zeile `ERGEBNIS` des Laufs. Was der IBKR-Teil nicht sieht: einen Tagesbericht (die Instanz hat keine Tagesauswertung), `handelstag_auswertung.py` und den Dienst `ibkr-gateway.service`. Holt die Kontrolle einen Tag während der New Yorker Handelszeit nach (Anmeldung am Nachmittag), liest sie das Konto nicht; der nächste Abendlauf liest es für seinen eigenen Tag. Den Zeitgeber liest sie zum Zeitpunkt der Kontrolle, nicht für den geprüften Tag: Beim Nachholen nach einem Umschalten kann sie einen Ausfall als „nicht gestartet“ werten oder einen No-Go-Tag als Befund. - -## Paket DH (Trading Plattform 0.38.0): Abendkontrolle vom Docker-Host - -Nach dem Umzug auf den Docker-Host (Hyper-V-VM) prüft der Host selbst: `deploy/docker-host/abendkontrolle-host.sh` aus dem Repo startet werktags 16:30 New Yorker Zeit `abendkontrolle_wsl.py` im Abbild der Plattform, mit `--journal` und `--dienste` statt `journalctl` und `systemctl` im Container — erst den Teil alpaca im Projekt `trading-alpaca`, dann den Teil ibkr im Projekt `trading-ibkr`, dort im Netz des Gateways und mit `--ibkr-python python`, weil es das Venv aus WSL im Abbild nicht gibt. Dafür liegen `abendkontrolle_wsl.py` und `handelstag_auswertung.py` als Kopie unter `/opt/trading-plattform/werkzeuge/` auf dem Host (Umzugs-Drehbuch, 4.4); wer eines davon hier ändert, kopiert es dort neu. Die Ergebnisse liegen je Tag unter `/var/lib/trading-abendkontrolle/.json` und `-ibkr.json`; scharf ist jeder Teil erst ab dem Datum in `erster-tag` beziehungsweise `erster-tag-ibkr` dort. - -| Datei | Was sie tut | Aufruf | -|---|---|---| -| `abendkontrolle.py` | seit Paket DH mit `--quelle wsl` (Vorgabe, wie bisher) oder `--quelle dockerhost` für den Teil alpaca und `--quelle-ibkr` für den Teil ibkr (ohne Angabe wie `--quelle`). `dockerhost` liest das Ergebnis des Hosts über `ssh.exe` als Benutzer `kontrolle` mit dem Schlüssel `~/.ssh/trading-dh-kontrolle`, `BatchMode` und `StrictHostKeyChecking=yes` gegen `~/.ssh/known_hosts_trading_dh`, angefragt mit `JJJJ-MM-TT` oder `JJJJ-MM-TT-ibkr`. Auf dem Host darf der Schlüssel nur `abendkontrolle-lesen.sh`. Exit 3 dort heißt „noch kein Ergebnis“: Notiz mit `#needs-review`, der nächste Lauf versucht es erneut | `python abendkontrolle.py --quelle dockerhost [--quelle-ibkr wsl] [--tag JJJJ-MM-TT]` | -| `abendkontrolle_einrichten.ps1` | seit Paket DH mit `-Quelle` und `-QuelleIbkr` (je `wsl` oder `dockerhost`); kommt ein Teil vom Host, liegt der Abendlauf auf 23:15. Die letzte Zeile nennt beide Quellen | `powershell -NoProfile -File abendkontrolle_einrichten.ps1 -Quelle dockerhost -QuelleIbkr wsl` | -| `abendkontrolle_dockerhost_angriffe.py` | zwölf Fälle ohne Netz, abgedichtet: `wsl.exe` zeigt ins Leere, und `fahre_wsl` lehnt ohne eigene Attrappe des Falls ab, ein Aufruf des echten Wegs ist eine Abweichung (so auch der Windows-Teil von `abendkontrolle_angriffe.py`). Die Fälle: beide Ergebnisse landen in Ablage und Notiz, Argumente von ssh je Teil, Exit 3 als Befund, Müll als Befund, fällige Tage einzeln je Teil, ohne Schalter bleibt es WSL, `--quelle-ibkr wsl` hält die Instanz in WSL, ein Ergebnis des falschen Teils vom Host, unbekannte Quelle, Frist und Aufgabe passen zueinander. Fängt je Fall auch `SystemExit` | `python abendkontrolle_dockerhost_angriffe.py` unter Windows | -| `abendkontrolle_ibkr_python_angriffe.py` | seit der Prüfung vom 2026-09-26 mit zweiter Sperre: `IBKR_ORDNER` zeigt ins Leere; dazu dichtet `rueckweg_aapl_angriffe.py` seither `WSL` und `WSL_SKRIPT` im Modul `rueckweg_aapl` ab, und `abendkontrolle_angriffe.py` räumt seinen leeren Ordner im Temp wieder weg. Sonst: vier Fälle ohne Netz für `--ibkr-python` von `abendkontrolle_wsl.py`: Schalter, Vorgabe `IBKR_PY`, `ibkr_pruefen.py lesen` läuft mit dem genannten Interpreter (Ersatzskript im Wegwerf-Ordner), der Ablauf reicht den Schalter weiter | `python abendkontrolle_ibkr_python_angriffe.py` in WSL im Venv | - -Was es nicht tut: Es prüft nicht, ob der Host die Kontrolle überhaupt fährt — bleibt sie dort aus, steht jeden Abend „noch kein Ergebnis“ in der Notiz, und das ist der Hinweis. Steht `-QuelleIbkr` falsch, fällt das nicht immer auf: Prüft die Aufgabe den Teil ibkr in WSL, obwohl die Instanz schon auf dem Host läuft, meldet sie ihn dort nur leise als „nicht gestartet“, weil der Zeitgeber in WSL aus ist. Ein Anmelde-Lauf zwischen 22:30 und dem Ende der Prüfung auf dem Host bekommt einmal „noch kein Ergebnis“, weil die Frist `FRUEHESTENS` für beide Quellen gilt (die Mutanten suchen genau diese Zeile). - -## Paket E9 (Trading Plattform 0.39.0): Sicherung nach D: - -Seit Paket E9 sichert der Docker-Host nach jedem Werktag um 16:45 New Yorker Zeit Register, `data/paper` und `artifacts/paper` beider Instanzen in seine Ablage (`deploy/docker-host/sicherung-host.sh` aus dem Repo). Die Werkzeuge hier holen die Sicherung nach `D:\Trading-Sicherung`, bestätigen sie beim Host und melden über die Abendkontrolle des Hosts, wenn sie fehlt oder unbestätigt ist. Plan im Projekt: `docs/plans/2026-10-11-paket-e9-sicherung/`; Codedoku dort in `docs/code/deployment.md`, Unterabschnitt „Sicherung (seit 0.39.0, Paket E9)“. - -- **Gebaut und geprüft, nicht eingespielt.** `SICHERUNG_ERSTER_TAG` in `abendkontrolle.py` steht auf `None`, bis der Koordinator E9 mit Pascals Ja einspielt; bis dahin holt `abendkontrolle.py` nichts ab. -- Beim Einspielen kommt `abendkontrolle_wsl.py` vor `host-einrichten.sh --anwenden` nach `/opt/trading-plattform/werkzeuge/`. Umgekehrt lehnt das alte Werkzeug `--sicherung` ab, und jede Abendkontrolle endet ohne Ergebnis. -- Die Zahlen der Fälle in den Abschnitten oben gelten für den Stand vor E9; wie viele es jetzt sind, sagt die Zählzeile des Laufs. -- Das Fenster von `abendkontrolle.py` (heute und zehn Kalendertage davor) ist kürzer als die Aufbewahrung des Hosts (zehn Sicherungstage). War der Rechner länger aus, fallen ältere Tage still heraus; die holt nur ein Aufruf mit `--tag`. -- Bis 0.39.0 scheiterte bei einem Lauf ohne erreichbaren Host jeder Tag des Fensters mit einer eigenen Zeile `#needs-review`, auch ein Tag, der schon vollständig auf D: lag (Quorum 4 zur Trading Plattform, Befund G1). Seit 0.39.1 steht dann genau eine Zeile in der Notiz von heute (Abschnitt „Paket NT“ unten). - -| Datei | Was sich ändert | Aufruf | -|---|---|---| -| `abendkontrolle.py` | holt für jeden Teil mit Quelle `dockerhost` nach den Ergebnissen die Sicherungen ab. Je Kalendertag der letzten `SICHERUNG_NACHHOLEN_TAGE` (10) bis heute ab `SICHERUNG_ERSTER_TAG` fragt es die Summe des Hosts (`sicherung-summe`); wo `.tar.sha256` auf D: fehlt oder eine andere Summe trägt, holt es das Archiv in `.tar.teil` (`sicherung`, 600 s), prüft die Summe, benennt nach `\.tar` um, bestätigt beim Host (`sicherung-bestaetigt`) und schreibt zuletzt die Summendatei. Exit 3 des Hosts ist für heute und das Wochenende still, für einen vergangenen Werktag ein Fehlschlag. Zeitbudget 1500 s ab dem Start des Laufs. Je Tag mit Neuem eine Zeile „Sicherung Trading Plattform (automatisch)“ in der Notiz dieses Tags, ein Fehlschlag mit `#needs-review`. Mit `--tag` nur dieser Tag, auch einer vor `SICHERUNG_ERSTER_TAG`. `--sicherung-ziel` lenkt die Ablage um, nur für die Angriffsfälle | `python abendkontrolle.py --quelle dockerhost --quelle-ibkr dockerhost [--tag JJJJ-MM-TT]` | -| `abendkontrolle_einrichten.ps1` | setzt den `StartBoundary` des Abend-Auslösers ohne UTC-Versatz (`JJJJ-MM-TTT23:15:00`), damit die Aufgabe nach Ortszeit startet (S10); wirkt erst, wenn die Aufgabe neu eingetragen ist | `powershell -NoProfile -File abendkontrolle_einrichten.ps1 -Quelle dockerhost -QuelleIbkr dockerhost` | -| `abendkontrolle_wsl.py` | `--sicherung `: prüft für den Teil die Sicherung des vorigen Handelstags nach dem Kalender (zehn Kalendertage vor `--tag` bis zum Vortag; die Spanne setzt das Werkzeug selbst durch, auch für eine Kalenderdatei) gegen den Stand, den `abendkontrolle-host.sh` als JSON schreibt: Eintrag, Summe, Archiv, Bestätigung gleich der Summe, dazu ein fehlender `erster-tag` und einer nach dem geprüften Tag. Jede Abweichung ist ein Befund und geht per ntfy hinaus; still bleibt nur ein voriger Handelstag vor dem ersten Sicherungstag. Ohne den Schalter wie vorher | auf dem Host über `abendkontrolle-host.sh`; von Hand in WSL `python abendkontrolle_wsl.py --tag JJJJ-MM-TT --sicherung --ohne-meldung` (ohne `--ohne-meldung` geht jeder Befund per ntfy hinaus) | -| `abendkontrolle_angriffe.py` | dazu die Fälle zur Sicherung in WSL: Stand unlesbar, `fehler`, `erster_tag` fehlt, kaputt oder nach dem geprüften Tag, jede Abweichung bei Eintrag, Summe, Archiv und Bestätigung, Grenzen des Kalenderfensters mit Kalenderdatei, Kalender in falscher Form, Bedienfehler und ein Meldefall gegen `ntfy_probe_server.py` | in WSL im Venv `python abendkontrolle_angriffe.py --nur-sicherung` (nur diese Fälle) oder ohne Schalter alle | -| `abendkontrolle_mutanten.py` | `--nur TEXT` fährt nur die Mutanten mit TEXT im Namen; ein fünftes Element im Tupel geht als Argument an den Treiber. Neu sind die Mutanten „Sicherung …“ von `abendkontrolle_wsl.py`; sie fahren den Treiber mit `--nur-sicherung`. Ohne `--nur` fährt jeder bisherige Mutant den ganzen Treiber samt Sicherungsfällen und dauert länger | in WSL im Venv `timeout 3600 python abendkontrolle_mutanten.py --nur Sicherung`; Exit 0, wenn keiner überlebt | -| `abendkontrolle_dockerhost_angriffe.py` | Die `ssh.exe`-Attrappe kennt die drei Sicherungsbefehle so streng wie `abendkontrolle-lesen.sh` (Formen, Exit 0 bis 4), gegen eine Host-Ablage im Wegwerf-Ordner. Neue Fälle zu Holen, Prüfen, Ablegen und Bestätigen, falscher Summe, abgebrochener Übertragung, Exit 3, schon vorhandener Datei, fremdem Inhalt im Zielordner, gescheiterter Bestätigung, Fenster, Zeitbudget und dem Auslöser der Aufgabe. Abgedichtet: `lauf()` setzt `SICHERUNG_ERSTER_TAG` auf `None` und `SICHERUNG_ZIEL` in den Wegwerf-Ordner, `sicherungslauf()` lenkt mit `--sicherung-ziel` um; unter `D:\Trading-Sicherung` landet nichts. `--mutanten` fährt erst die Grundlinie, dann `MUTANTEN` auf Kopien von `abendkontrolle.py` und `MUTANTEN_AUFGABE` auf Kopien der `.ps1` (als Text); jeder Mutant nennt die Fälle, die rot werden müssen | unter Windows `PYTHONIOENCODING=utf-8 python -X utf8 abendkontrolle_dockerhost_angriffe.py [--mutanten]`; Exit 0 ohne Abweichung, sonst 2 | - -Was die Kette nicht prüft: - -- Ob das echte `ssh.exe` die Bytes eines Archivs unverändert lässt. Jede Abweichung fiele als falsche Summe auf; den Nachweis bringt erst das erste echte Holen. -- Dass `cmd.exe` stdout der `.cmd`-Attrappe unberührt durchreicht, ist abgeleitet; der Fall mit CR, LF und Strg-Z im Archiv zeigt es. -- Die Units des Hosts, SELinux, das Packen auf dem Host, das Zurückspielen und ob die Kopie auf D: noch liegt; Veeam und die VHDX auch nicht. -- Ein von Hand verändertes Archiv neben passender Summendatei auf D:: `abendkontrolle.py` hasht nicht nach. Das zeigt erst `sha256sum -c` im Tagesordner. -- Eine Bestätigung belegt nur, dass der Absender die Summe des Hosts kennt, nicht, dass er geholt hat; derselbe Schlüssel liest die Summe. -- `abendkontrolle_mutanten.py` zählt jeden Exit ungleich 0 des Treibers als gefangen und zeigt je Mutant nur die ersten zwei `FEHL`-Zeilen. Dass der gemeinte Fall rot wird, belegen dort nur diese Zeilen und der Rot-Nachweis im Plan. -- Ohne eigenen Mutanten: `chmod 0644` der JSON-Datei auf dem Host, die Prüfung vor der Meldung (nur ein Fall belegt sie) und die Spanne auf der Antwort von Alpaca (kein Fall fährt den Netzweg). -- Ein `--tag` in falscher Form: Bis 0.39.0 prüfte der Fall dazu nur, dass keine Sicherung geholt wird, und die Abendkontrolle davor schrieb eine Notiz mit „gescheitert“, bei `../…` außerhalb von `Inbox/`. Seit 0.39.1 endet der Lauf gleich mit Exit 2, und der Fall `ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh` prüft das (Abschnitt „Paket NT“ unten). - -## Paket NT (Trading Plattform 0.39.1): Sicherung bei Hostausfall, `--tag` früh geprüft - -Seit Paket NT bricht `abendkontrolle.py` die Sicherung ab, wenn der Docker-Host nicht erreichbar ist, und prüft `--tag`, bevor es etwas anfängt. Plan im Projekt: `docs/plans/2026-10-11-paket-nt-nachtraege/` (Task 729 und 739); Codedoku dort in `docs/code/deployment.md`, Unterabschnitt „Sicherung (seit 0.39.0, Paket E9)“, Absatz „Abholen unter Windows“. - -- **Wirkt ab dem nächsten Start der Windows-Aufgabe,** nicht erst beim Einspielen: Sie führt `abendkontrolle.py` aus diesem Ordner aus, abends und fünf Minuten nach jeder Anmeldung. Solange `SICHERUNG_ERSTER_TAG` `None` ist, holt sie keine Sicherung; sofort wirkt nur die frühe Prüfung von `--tag`. -- **Host nicht erreichbar:** Endet `sicherung-summe` oder `sicherung-bestaetigt` mit ssh-Exit 255 oder im Zeitlimit `SSH_TIMEOUT_S` (120 s), schreibt der Lauf genau eine Zeile „… **gescheitert** #needs-review. Docker-Host nicht erreichbar (…), Sicherung abgebrochen. … Der nächste Lauf versucht es erneut.“ in die Notiz von heute und endet mit Exit 1. Fertige Tage behalten ihre Zeilen; was ein angefangener Tag schon hat, steht hinter „Bis dahin am …“. -- **Abbruch im Archiv:** Exit 255 oder Zeitlimit `SICHERUNG_TIMEOUT_S` (600 s) bei `sicherung` lässt nur diese Instanz scheitern, mit „Archiv nicht geholt (…)“; der Lauf macht weiter. -- **`--tag`** außer der Form `JJJJ-MM-TT`, ohne gültiges Datum oder leer: eine Zeile auf stderr, Exit 2, keine Notiz, kein `lauf.log`, kein ssh, kein WSL. -- `abendkontrolle_wsl.py` nennt im Kopf-Docstring die zwei neuen `fehler`-Gründe aus `abendkontrolle-host.sh` (Summendatei oder Bestätigung nicht lesbar); sein Verhalten ändert sich nicht. -- Stand 0.39.1: Der Treiber hat 39 Fälle und 57 Mutanten. Weicht die Zählzeile eines Laufs davon ab, gilt sie. - -| Datei | Was sich ändert | Aufruf | -|---|---|---| -| `abendkontrolle.py` | `ssh_lauf` erkennt den Ausfall (`HostNichtErreichbar`); `hole_sicherungen` bricht bei Summe und Bestätigung ab und schreibt die eine Zeile, beim Archiv scheitert nur die Instanz; `main` prüft `--tag` über `tag_gueltig` gleich nach dem Zerlegen, `sicherungstage` prüft den Tag nicht mehr selbst; der Kommentar über `SICHERUNG_NACHHOLEN_TAGE` beginnt nicht mehr mit „(S3).“; der Docstring von `ssh_lauf` sagt, dass Exit 255 bei einem Signal auf dem Host nur für OpenSSH-portable 9.5p1 nachgelesen ist, nicht für Win32-OpenSSH 9.5p2 | `python abendkontrolle.py --quelle dockerhost --quelle-ibkr dockerhost [--tag JJJJ-MM-TT]` | -| `abendkontrolle_dockerhost_angriffe.py` | Die `ssh.exe`-Attrappe kennt drei Störungen mehr: `ANGRIFF_HOST_WEG=` lässt jeden Aufruf nach den ersten n mit Exit 255 enden wie ssh ohne Host, `ANGRIFF_SICHERUNG=langsam` hält jeden Sicherungsbefehl 5 s auf, `ANGRIFF_SICHERUNG=archiv_unlesbar` endet beim Archiv wie `abendkontrolle-lesen.sh` ohne Leserecht (Exit 1). `lauf()` nimmt `ANGRIFF_HOST_WEG` aus der Umgebung, `sicherungslauf()` setzt es über den Schalter `host_weg`. Neue Fälle: `ein_unlesbares_archiv_auf_dem_host_scheitert_nur_fuer_seine_instanz`, `ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz`, `ein_host_der_mitten_im_lauf_wegfaellt_laesst_die_zeilen_davor_stehen`, `ein_zeitlimit_beim_host_zaehlt_wie_ein_unerreichbarer_host`, `ein_zeitlimit_beim_archiv_laesst_nur_diese_instanz_scheitern`, `eine_bestaetigung_ohne_host_bricht_die_sicherung_ab` (Task 739) und `kein_kommentar_beginnt_mit_s3`. `ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh` ersetzt `ein_tag_ausser_der_form_holt_keine_sicherung`; `eine_abgebrochene_uebertragung_hinterlaesst_nichts` erwartet einen Grund mit dem Befehl. In `MUTANTEN` ersetzen E729-01 bis E729-15 und E729-25 die Mutanten „Tag ungeprueft“ und „Datum ungeprueft“; dazu kommt E739-01, und „Exit des Archivs egal“ zielt jetzt auf den Fall mit dem unlesbaren Archiv | unter Windows `PYTHONIOENCODING=utf-8 python -X utf8 abendkontrolle_dockerhost_angriffe.py [--mutanten]`; Exit 0 ohne Abweichung, sonst 2 | -| `abendkontrolle_wsl.py` | nur der Kopf-Docstring: die zwei neuen `fehler`-Gründe | wie im Abschnitt Paket E9 | - -Was die Kette nicht prüft: - -- Ob Exit 255 unter Win32-OpenSSH 9.5p2 auch kommt, wenn auf dem Host ein Signal `abendkontrolle-lesen.sh` beendet. Laut Quelltext von OpenSSH-portable 9.5p1 ja; dann hieße die Zeile bei Summe oder Bestätigung „Docker-Host nicht erreichbar“, obwohl er erreichbar war. -- Bis 0.39.2 fehlte auch die Zeile in der Notiz, wenn sich `lauf.log` nicht schreiben ließ; übrig blieb Exit 1 (kritische Prüfung NT, Befund 3, so seit E9). Seit 0.39.3 hält das Ergebnisdateien, Zeilen und Sicherung nicht mehr auf, mit sieben Fällen dazu (Abschnitt „Paket NR“ unten). -- Bricht das Archiv der letzten Instanz des letzten Tags ab, weil der Host gerade wegfällt, folgt keine Frage nach einer Summe mehr; die Zeile nennt dann nur „Archiv nicht geholt (… Exit 255 …)“, nicht „Docker-Host nicht erreichbar“. -- Bricht das erste Archiv eines Tags ab, bleibt der leere Tagesordner auf D: zurück (so seit E9). -- Ohne eigenen Fall: die ASCII-Darstellung des Tags in der Meldung zu `--tag` (ein Mutant `!a` nach `!r` überlebte), und „kein WSL“ bei falschem `--tag` misst der Fall nur mit `--quelle dockerhost`. -- Ein Abend mit ausgeschaltetem Host über das echte `ssh.exe`. - -## Paket NR (Trading Plattform 0.39.3): `lauf.log` und Ergebnisdatei halten die Notizzeile nicht auf - -Seit Paket NR hält ein nicht schreibbares `lauf.log` in `abendkontrolle.py` weder Ergebnisdatei noch Notizzeile noch Sicherung auf, und eine nicht schreibbare Ergebnisdatei weder die Zeile ihres Tages noch die folgenden Tage. Plan im Projekt: `docs/plans/2026-10-12-paket-nr-pruefreste/` (Task 743); Codedoku dort in `docs/code/deployment.md`, Unterabschnitt „Sicherung (seit 0.39.0, Paket E9)“, Absatz „Bekannte Grenzen“. - -- **Wirkt ab dem nächsten Start der Windows-Aufgabe,** nicht erst mit einem Abbild: Sie führt `abendkontrolle.py` aus diesem Ordner aus, abends und fünf Minuten nach jeder Anmeldung. -- **`lauf.log` nicht schreibbar** (Sperre, Rechte, volle Platte; jede Ausnahme beim Anlegen der Ablage, beim Öffnen oder beim Schreiben): `logge` wirft nicht mehr und merkt sich den Typ der Ausnahme in `LOG_FEHLER`, `main` setzt ihn zu Beginn jedes Laufs zurück. Am Ende, nach der Sicherung, schreibt der Lauf genau eine Zeile in die Notiz von heute, auch wenn `--tag` einen anderen Tag nennt: `Abendkontrolle lauf.log (automatisch): **nicht schreibbar** () #needs-review. Mindestens ein Eintrag dieses Laufs fehlt dort; …`, und endet mit Exit 1. Kein Pfad, kein Inhalt, nur der Typ der letzten Ausnahme. Scheitert auch diese Notiz, endet `main` ohne Ausnahme mit Exit 1. -- **Ergebnisdatei nicht schreibbar:** Die Zeile des Tages entsteht trotzdem, mit der Unterzeile `Ergebnisdatei nicht geschrieben () #needs-review. …`; die folgenden Tage des Teils laufen weiter, `lauf.log` bekommt einen Eintrag, Exit 1. -- Stand 0.39.3: Der Treiber hat 46 Fälle und 70 Mutanten. Weicht die Zählzeile eines Laufs davon ab, gilt sie. - -| Datei | Was sich ändert | Aufruf | -|---|---|---| -| `abendkontrolle.py` | `logge` fängt jede Ausnahme und setzt `LOG_FEHLER`; `main` setzt ihn zurück und schreibt am Ende die Zeile mit dem Kopf `LOG_KOPF`; `fahre_teil` fängt einen Fehler beim Schreiben der Ergebnisdatei, hängt die Unterzeile an die Zeile des Tages und macht mit dem nächsten Tag weiter; Kopf-Docstring und ein Kommentar in `main` nachgezogen | `python abendkontrolle.py --quelle dockerhost --quelle-ibkr dockerhost [--tag JJJJ-MM-TT]` | -| `abendkontrolle_dockerhost_angriffe.py` | Neue Hilfsfunktionen `lauf_log_sperren` (legt an der Stelle von `lauf.log` einen Ordner an; Öffnen zum Anhängen scheitert dann wie bei einer Sperre, und der Fall misst den Typ der Ausnahme selbst) und `typ_beim_schreiben` (misst den Typ, wenn sich ein Pfad nicht als Datei schreiben lässt). Sieben neue Fälle: `ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf`, `ein_gesperrtes_lauf_log_verschluckt_die_zeile_zum_unerreichbaren_host_nicht`, `ein_gesperrtes_lauf_log_verschluckt_die_zeilen_gescheiterter_teile_nicht`, `eine_gesperrte_notiz_neben_gesperrtem_lauf_log_bricht_main_nicht_ab`, `ein_fehler_in_logge_der_kein_oserror_ist_haelt_den_lauf_nicht_auf`, `eine_ablage_die_sich_nicht_anlegen_laesst_haelt_die_zeilen_nicht_auf` und `eine_nicht_schreibbare_ergebnisdatei_haelt_weder_zeile_noch_folgende_tage_auf`. In `MUTANTEN` dazu E743-01 bis E743-13 | unter Windows `PYTHONIOENCODING=utf-8 python -X utf8 abendkontrolle_dockerhost_angriffe.py [--mutanten]`; Exit 0 ohne Abweichung, sonst 2 | - -Was die Kette nicht prüft: - -- Ein echt gesperrtes `lauf.log` (ein anderes Programm, volle Platte): Nachgestellt ist es als Ordner an der Stelle von `lauf.log`; den Typ der Ausnahme misst der Fall selbst, statt ihn je Betriebssystem zu raten. -- Ohne eigenen Mutanten: dass `fahre_teil` die Unterzeile für jeden Tag neu leert. Der Fall zur Ergebnisdatei prüft aber, dass der Folgetag keine Unterzeile trägt. -- Eine halb geschriebene Ergebnisdatei: Sie zählt für `faellige_tage` als erledigt (`exists()`), kein Lauf prüft den Tag erneut, und der Lauf räumt nichts weg. Kein Fall dazu; die Unterzeile sagt es. -- `rueckweg_aapl.py` schreibt `lauf.log` ungeschützt vor der Notizzeile, `abbau_restbestand.py` schluckt Fehler an `abbau.log` still: derselbe Mechanismus, ohne Änderung in NR. Die Aufgabe „Rueckweg AAPL“ wird in Phase 9 entfernt, die Abbau-Werkzeuge sollen stillgelegt werden (Punkt im `todo.md` des Projekts). - -## Paket AB (2026-10-07): Abbau des Restbestands nach dem Netzschalter-Abriss - -Am 2026-10-07 um 20:13 beendete ein Druck auf den Netzschalter beide Paper-Runner mitten im Handel, ohne Glattstellen. Pascal am selben Abend: am 2026-10-08 soweit es geht alles verkaufen, Testläufe bis Dienstag gestoppt. Die Zeitgeber `trading-paper.timer` und `trading-paper-ibkr.timer` sind seitdem aus, die Abendkontrolle ebenfalls; die Meldung dieses Werkzeugs ist in der Pause das einzige Netz. Plan im Projekt: `docs/plans/2026-10-07-paket-ab-abbau-restbestand/`. - -| Datei | Was sie tut | Aufruf | -|---|---|---| -| `abbau_restbestand.py` | Windows-Seite. Im Modus `scharf` nur zwischen 2026-10-08 15:40 und 2026-10-09 21:45: Runner-Units über `systemctl show` (nur `LoadState=loaded` mit `inactive` oder `failed` gilt als stehend), Marktuhr, beide Bestände; je Instanz mit Position oder offener Order genau ein Glattstellen über `abbau_glattstellen.sh`; nach 30 s beide Bestände neu; Ergebnis in `data/paper/abbau/abbau.json` und `abbau.log` im Projekt, mit `"flach": true` enden alle späteren Läufe still; bei Eingriff, Befund oder dem ersten "flach" eine Zeile in der Tagesnotiz und eine Meldung mit hoher Priorität. Fail-closed: laufender oder nicht geladener Runner, unlesbarer Zustand, unlesbare Marktuhr oder unlesbarer Bestand heißt nicht angefasst, mit Befund; Markt zu oder weniger als 10 min bis Schluss heißt nichts, ohne Meldung. `wsl()` setzt `WSL_UTF8=1` und entfernt NUL; Befunde aus Ausnahmen nennen nur den Typ, der Traceback steht nur in `abbau.log`. `--modus lesen` fährt nur die lesenden Aufrufe, auch außerhalb des Fensters, und schreibt nichts; ohne `--modus` Exit 2 | `python abbau_restbestand.py --modus lesen`; `scharf` nur über die Aufgabe | -| `abbau_glattstellen.sh` | WSL. Liest das Bestätigungswort aus `config/killswitch.toml` der Instanz, wählt den Broker ausdrücklich (`--broker alpaca` oder `--broker ibkr`) und ruft genau einmal `scripts/killswitch.py glattstellen` im Instanzordner mit deren Venv. Die Ausgabe geht durch einen Filter: das Wort wird `[wort]`, dann `ohne_kontonummer()` (Kontonummern als `[konto]`, dann `maskiere()`), dann jede Dezimalzahl `[x]`. Kennt keinen echten Ordner, der Aufrufer nennt ihn. `--broker attrappe` ersetzt die Wahl, nur für die Probe in einer Wegwerfkopie | `bash abbau_glattstellen.sh [--broker attrappe]` in WSL, Instanz `alpaca` oder `ibkr` | -| `abbau_alpaca_lesen.py` | WSL, Venv `~/venvs/trading`: Marktuhr, Positionen (nur Symbol und Stück) und Zahl der offenen Orders des Alpaca-Kontos als eine JSON-Zeile; Fehlertexte durch `ohne_kontonummer()` und die Dezimalmaske | nur über `abbau_restbestand.py`; von Hand `python abbau_alpaca_lesen.py --projekt ` | -| `abbau_ibkr_lesen.sh` | WSL: `scripts/ibkr_pruefen.py lesen` im IBKR-Ordner mit `~/venvs/trading-ibkr` (Client-ID 12, lesend), nie `orderweg` | nur über `abbau_restbestand.py` | -| `abbau_restbestand_angriffe.py` | Angriffsfälle als Funktionen `test_*`, lauffähig als Skript und unter pytest (für `mutanten_lauf.py`). Abgedichtet: alle Pfade nach draußen zeigen ins Leere, `wsl()` ist durch eine zählende Attrappe ersetzt, im Fall zu `wsl()` selbst auch `subprocess.run`. Die Fälle `test_wsl_*` fahren den echten Wrapper und die Venvs gegen Wegwerf-Instanzordner mit falschem `killswitch.py`, erfundenem Wort, erfundener Kontonummer und erfundenen Beträgen; unter Windows übersprungen | unter Windows `python -I -X utf8 abbau_restbestand_angriffe.py`, in WSL `~/venvs/trading/bin/python -I abbau_restbestand_angriffe.py`; Mutanten: `mutanten.toml` im Planordner | -| `abbau_restbestand_einrichten.ps1` | Aufgabe "Trading Plattform - Abbau Restbestand": Do 2026-10-08 15:45, 16:15, 17:00, 19:00, 21:00, Ersatz Fr 2026-10-09 15:45, 16:30, 19:00 und fünf Minuten nach jeder Anmeldung; nur angemeldet, Zeitlimit 45 min, keine zweite Instanz; druckt nach dem Registrieren die Einstellungen | `powershell -NoProfile -File abbau_restbestand_einrichten.ps1 [-Aktion entfernen]` | - -Was es nicht tut: Es gibt keinen Killswitch frei. Jede angefasste Instanz steht danach auf `gesperrt`, Freigeben bleibt Pascal (`scripts/killswitch.py freigeben`). Es hat keinen eigenen Ordercode und keine Sperrdatei gegen einen zweiten Lauf von Hand; die Aufgabe selbst startet keine zweite Instanz. Während des Aufrufs steht das Wort in WSL in der Prozessliste von `killswitch.py`, dessen Schnittstelle es als Argument nimmt. Ob `wsl.exe --exec` eine Meldung mit Leerzeichen so weitergibt wie ein Skript mit Argumenten, war vor dem ersten scharfen Lauf nicht gemessen; was die Meldung ergab, steht in der Notizzeile unter "Meldung ans Telefon". Nach dem 2026-10-09 wird die Aufgabe mit `-Aktion entfernen` abgebaut. diff --git a/.vault-tools/trading-plattform-pruefung/abbau_alpaca_lesen.py b/.vault-tools/trading-plattform-pruefung/abbau_alpaca_lesen.py deleted file mode 100644 index b47399f..0000000 --- a/.vault-tools/trading-plattform-pruefung/abbau_alpaca_lesen.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Abbau Restbestand: liest Marktuhr, Positionen und offene Orders des Alpaca-Paper-Kontos, nur lesend. - -Ersteller: Pascal Michel -Erstellt: 2026-10-07 -Doku: LIESMICH.md in diesem Ordner, Abschnitt "Paket AB" - -Laeuft in WSL mit dem Venv des Alpaca-Ordners (~/venvs/trading); gestartet von abbau_restbestand.py -ueber wsl.exe --exec. Ruft nur marktuhr(), positionen() und offene_orders() von AlpacaBroker -(trading/broker/alpaca.py: GET /v2/clock, /v2/positions, /v2/orders?status=open). Nichts davon -schreibt beim Broker oder auf der Platte. -Ausgabe: genau eine JSON-Zeile auf stdout mit - uhr {"offen": bool, "sekunden_bis_schluss": int} oder null - uhr_fehler Text oder null - positionen [{"symbol": str, "menge": int}] oder null; menge ist negativ bei short - orders Zahl der offenen Orders oder null - bestand_fehler Text oder null -Nie ein Betrag: Von einer Position gehen nur Symbol und Stueckzahl weiter, von den Orders nur die -Zahl. Fehlertexte laufen durch ohne_kontonummer() aus abendkontrolle_wsl.py (Kontonummern, dann -maskiere()) und danach durch DEZIMAL: jede Dezimalzahl wird [x], ganze Zahlen bleiben. -Den Projektordner nennt der Aufrufer (--projekt); hier steht kein echter Pfad. -Aufruf: python abbau_alpaca_lesen.py --projekt -Exit: 0 mit der JSON-Zeile (auch wenn darin Fehler stehen), 2 Bedienfehler. -""" - -import argparse -import json -import os -import re -import sys -from datetime import datetime -from datetime import timezone -from pathlib import Path - -HIER = Path(__file__).resolve().parent -sys.path.insert(0, str(HIER)) -from abendkontrolle_wsl import ohne_kontonummer # noqa: E402 - -GEHEIMNISSE = "/home/claude/.config/trading-plattform/secrets.toml" -RISIKO = "config/risk.toml" -FEHLERTEXT_MAX = 200 -# Jede Dezimalzahl, auch ohne Geldwort davor; maskiere() faengt nur Zahlen hinter Geldwoertern, -# vor Waehrungen oder mit vielen Stellen. -DEZIMAL = re.compile(r"-?\d+[.,]\d+") - - -def fehlertext(ausnahme: BaseException) -> str: - """Typ und Text einer Ausnahme: Kontonummern als [konto], Betraege als [x], gekuerzt.""" - text = ohne_kontonummer(f"{type(ausnahme).__name__}: {ausnahme}") - return DEZIMAL.sub("[x]", text)[:FEHLERTEXT_MAX] - - -def lies(broker, jetzt: datetime) -> dict: - """Marktuhr und Bestand; jede Frage fuer sich, ein Fehler in der einen haelt die andere nicht auf.""" - ergebnis = {"uhr": None, "uhr_fehler": None, "positionen": None, "orders": None, "bestand_fehler": None} - try: - uhr = broker.marktuhr() - ergebnis["uhr"] = { - "offen": bool(uhr.is_open), - "sekunden_bis_schluss": int((uhr.next_close - jetzt).total_seconds()), - } - except Exception as ausnahme: - ergebnis["uhr_fehler"] = fehlertext(ausnahme) - try: - positionen = broker.positionen() - orders = broker.offene_orders() - ergebnis["positionen"] = [ - {"symbol": position.symbol, "menge": int(position.menge)} for position in positionen - ] - ergebnis["orders"] = len(orders) - except Exception as ausnahme: - ergebnis["positionen"] = None - ergebnis["orders"] = None - ergebnis["bestand_fehler"] = fehlertext(ausnahme) - return ergebnis - - -def main(argv=None) -> int: - zerleger = argparse.ArgumentParser(description=__doc__.splitlines()[0], allow_abbrev=False) - zerleger.add_argument("--projekt", required=True, help="Ordner der Alpaca-Instanz in WSL") - argumente = zerleger.parse_args(argv) - os.environ.setdefault("TRADING_GEHEIMNISSE", GEHEIMNISSE) - try: - os.chdir(argumente.projekt) - sys.path.insert(0, str(Path(argumente.projekt).resolve())) - from trading.broker.alpaca import AlpacaBroker - from trading.risk.limits import lade_limits - - broker = AlpacaBroker.from_secrets(None, universum=lade_limits(RISIKO).universum) - except Exception as ausnahme: - grund = fehlertext(ausnahme) - print(json.dumps( - {"uhr": None, "uhr_fehler": grund, "positionen": None, "orders": None, "bestand_fehler": grund}, - ensure_ascii=False, - )) - return 0 - print(json.dumps(lies(broker, datetime.now(timezone.utc)), ensure_ascii=False)) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/abbau_glattstellen.sh b/.vault-tools/trading-plattform-pruefung/abbau_glattstellen.sh deleted file mode 100644 index e6336a1..0000000 --- a/.vault-tools/trading-plattform-pruefung/abbau_glattstellen.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -# Abbau Restbestand: stellt eine Paper-Instanz ueber scripts/killswitch.py glattstellen glatt. -# Ersteller: Pascal Michel Erstellt: 2026-10-07 -# Doku: LIESMICH.md in diesem Ordner, Abschnitt "Paket AB" -# -# Das Bestaetigungswort verlaesst WSL nie: Dieses Skript liest es aus config/killswitch.toml der -# Instanz und reicht es nur an killswitch.py weiter. Jede Zeile von killswitch.py geht danach durch -# einen Filter: das Wort wird [wort], dann ohne_kontonummer() aus abendkontrolle_wsl.py -# (Kontonummern als [konto], dann maskiere()), dann jede Dezimalzahl als [x]. -# Den Broker waehlt das Skript ausdruecklich: alpaca -> --broker alpaca, ibkr -> --broker ibkr -# (gemessen: ibkr ist im IBKR-Ordner mit Marke ibkr erlaubt, alpaca im Projektordner ohne Marke). -# --broker attrappe ersetzt die Wahl, nur fuer die Probe in einer Wegwerfkopie und die Angriffsfaelle. -# Den Instanzordner nennt immer der Aufrufer (abbau_restbestand.py); hier steht kein echter Pfad. -# Genau ein Aufruf von killswitch.py je Start, keine Schleife. -# -# Aufruf: bash abbau_glattstellen.sh [--broker attrappe] -# Exit: der von killswitch.py (0 erfolgreich, 1 nicht erfolgreich, 3 abgelehnt, 4 Voraussetzung), -# 2 Bedienfehler, 4 Wort nicht lesbar oder Instanzordner nicht erreichbar. -set -u - -GRUND="Abbau Restbestand nach Netzschalter-Abriss 2026-10-07; Auftrag Pascal 2026-10-07, ausgefuehrt von der Aufgabe Abbau Restbestand" -HIER="$(dirname "$(readlink -f "$0")")" -export TRADING_GEHEIMNISSE=/home/claude/.config/trading-plattform/secrets.toml -export PYTHONIOENCODING=utf-8 - -if [ "$#" -lt 2 ]; then - echo "Bedienfehler: Aufruf abbau_glattstellen.sh [--broker attrappe]" >&2 - exit 2 -fi -instanz="$1" -ordner="$2" -shift 2 -case "$instanz" in - alpaca) - py=/home/claude/venvs/trading/bin/python - broker=(--broker alpaca) - ;; - ibkr) - py=/home/claude/venvs/trading-ibkr/bin/python - broker=(--broker ibkr) - ;; - *) - echo "Bedienfehler: Instanz muss alpaca oder ibkr sein" >&2 - exit 2 - ;; -esac -while [ "$#" -gt 0 ]; do - case "$1" in - --broker) - if [ "$#" -lt 2 ] || [ "$2" != "attrappe" ]; then - echo "Bedienfehler: --broker nimmt nur attrappe" >&2 - exit 2 - fi - broker=(--broker attrappe) - shift 2 - ;; - *) - echo "Bedienfehler: unbekannte Angabe" >&2 - exit 2 - ;; - esac -done - -if ! cd "$ordner"; then - echo "Voraussetzung fehlt: Instanzordner nicht erreichbar" >&2 - exit 4 -fi - -# Das Wort, nur in dieser Shell; tomllib aus dem Python der Instanz. -wort="$("$py" -I -c 'import tomllib -with open("config/killswitch.toml", "rb") as datei: - print(tomllib.load(datei)["killswitch"]["bestaetigungswort"])' 2>/dev/null)" -if [ -z "$wort" ]; then - echo "Voraussetzung fehlt: Bestaetigungswort in config/killswitch.toml nicht lesbar" >&2 - exit 4 -fi - -# Genau ein Aufruf; stdout und stderr zusammen, gefiltert erst hinter dem Lauf. -ausgabe="$("$py" scripts/killswitch.py "${broker[@]}" glattstellen --grund "$GRUND" --bestaetigung "$wort" 2>&1)" -code=$? - -# Filter je Zeile: Wort, Kontonummern und maskiere(), Dezimalzahlen. Das Wort geht nur ueber die Umgebung. -if ! printf '%s\n' "$ausgabe" | ABBAU_WORT="$wort" "$py" -I -c 'import os -import re -import sys -sys.path.insert(0, sys.argv[1]) -from abendkontrolle_wsl import ohne_kontonummer -wort = os.environ.pop("ABBAU_WORT", "") -for zeile in sys.stdin.read().splitlines(): - if wort: - zeile = zeile.replace(wort, "[wort]") - zeile = ohne_kontonummer(zeile) - print(re.sub(r"-?\d+[.,]\d+", "[x]", zeile)[:300])' "$HIER"; then - echo "Ausgabe von killswitch.py nicht gefiltert und deshalb zurueckgehalten" -fi -unset wort ausgabe -exit "$code" diff --git a/.vault-tools/trading-plattform-pruefung/abbau_ibkr_lesen.sh b/.vault-tools/trading-plattform-pruefung/abbau_ibkr_lesen.sh deleted file mode 100644 index 341a6cf..0000000 --- a/.vault-tools/trading-plattform-pruefung/abbau_ibkr_lesen.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -# Abbau Restbestand: liest Positionen und offene Orders der IBKR-Instanz, nur lesend (ibkr_pruefen.py lesen). -# Ersteller: Pascal Michel Erstellt: 2026-10-07 -# Doku: LIESMICH.md in diesem Ordner, Abschnitt "Paket AB" -# -# Faehrt scripts/ibkr_pruefen.py lesen im Instanzordner mit ~/venvs/trading-ibkr (Client-ID 12, -# lesend); den Unterbefehl orderweg ruft es nie. Den Instanzordner nennt der Aufrufer -# (abbau_restbestand.py); hier steht kein echter Pfad. -# Aufruf: bash abbau_ibkr_lesen.sh -# Exit: der von ibkr_pruefen.py (0 gelesen, 1 Befund, 4 Voraussetzung fehlt), 2 Bedienfehler, -# 4 Instanzordner nicht erreichbar. -set -u - -if [ "$#" -ne 1 ]; then - echo "Bedienfehler: Aufruf abbau_ibkr_lesen.sh " >&2 - exit 2 -fi -export TRADING_GEHEIMNISSE=/home/claude/.config/trading-plattform/secrets.toml -export PYTHONIOENCODING=utf-8 -if ! cd "$1"; then - echo "Voraussetzung fehlt: Instanzordner nicht erreichbar" >&2 - exit 4 -fi -exec /home/claude/venvs/trading-ibkr/bin/python scripts/ibkr_pruefen.py lesen diff --git a/.vault-tools/trading-plattform-pruefung/abbau_restbestand.py b/.vault-tools/trading-plattform-pruefung/abbau_restbestand.py deleted file mode 100644 index 5f968df..0000000 --- a/.vault-tools/trading-plattform-pruefung/abbau_restbestand.py +++ /dev/null @@ -1,575 +0,0 @@ -"""Abbau Restbestand: stellt am 2026-10-08 beide Paper-Konten ueber den Notweg glatt, Ersatztag 2026-10-09. - -Ersteller: Pascal Michel -Erstellt: 2026-10-07 -Doku: LIESMICH.md in diesem Ordner, Abschnitt "Paket AB" - -Koordinator-Werkzeug, nicht fuers Repo. Am 2026-10-07 um 20:13 beendete ein Druck auf den -Netzschalter beide Paper-Runner mitten im Handel, ohne Glattstellen. Pascal am selben Abend: -morgen soweit es geht alles verkaufen, Testlaeufe bis Dienstag gestoppt. Plan im Projekt: -docs/plans/2026-10-07-paket-ab-abbau-restbestand/. Gestartet von der Windows-Aufgabe -"Trading Plattform - Abbau Restbestand" (abbau_restbestand_einrichten.ps1) mit --modus scharf. - -Ablauf im Modus scharf, jeder Schritt mit seinem Grund: - 1. Nur zwischen FRUEHESTENS und SPAETESTENS (Uhr dieses Rechners); sonst nichts, auch keine Datei. - Ergebnisdatei mit "flach": true -> nichts mehr, nur eine Zeile im Log. - 2. Runner-Units (systemctl show, LoadState und ActiveState): Laeuft eine, ist sie nicht geladen - oder ihr Zustand nicht lesbar, wird diese Instanz nicht angefasst (Befund). systemctl is-active - meldete auch fuer eine Unit, die es nicht gibt, inactive. Die Zeitgeber sind seit 2026-10-07 aus. - 3. Marktuhr von Alpaca (abbau_alpaca_lesen.py): zu oder weniger als MIN_BIS_SCHLUSS_S bis zum - Schluss -> nichts, nur eine Zeile im Log. Nicht lesbar -> Befund, nichts angefasst. - 4. Bestaende beider Instanzen; nicht lesbar -> Befund, diese Instanz nicht angefasst. - 5. Je Instanz mit Position oder offener Order genau ein Aufruf von abbau_glattstellen.sh - (scripts/killswitch.py glattstellen; das Bestaetigungswort bleibt in WSL). Keine Schleife: - Der naechste Termin der Aufgabe ist die Wiederholung. Wirft der Aufruf, gilt Exit None. - 6. Nach einem Eingriff WARTEN_S warten und beide Bestaende neu lesen. - 7. Ergebnisdatei abbau.json; "flach": true nur, wenn beide Instanzen nachher ohne Position und - ohne offene Order gelesen sind und keine Instanz gesperrt ist. Sonst beobachtet der naechste - Termin weiter. - 8. Bei Eingriff, Befund oder dem ersten "flach" eine Zeile in Inbox/.md und eine Meldung mit - hoher Prioritaet (abendkontrolle_wsl.py --meldung). Die Abendkontrolle ist in der Pause aus; - diese Meldung ist das einzige Netz. -Modus lesen: nur die lesenden Aufrufe, ohne Fenster und ohne die Sperre durch "flach"; kein -Glattstellen, keine Meldung, keine Notiz, keine Datei. Das Ergebnis steht auf stdout. -Jeder Aufruf nach draussen geht ueber wsl(); die Angriffsfaelle ersetzen sie. wsl.exe schreibt eigene -Fehler in UTF-16LE: wsl() setzt WSL_UTF8=1 und entfernt NUL, sonst scheiterte die Meldung an einem -NUL im Argument. Ausgabe und Befunde sind einzeilig: Ein fremder Fehlertext mit Zeilenumbruch -setzte sonst eigene Zeilen, bis hin zu einer Ueberschrift, in die Tagesnotiz. Befunde aus Ausnahmen -nennen nur den Typ; der Traceback steht nur in abbau.log. -Annahme, benannt wie in abendkontrolle.py: Die Uhr dieses Rechners geht nach Berliner Zeit. -Aufruf: python abbau_restbestand.py --modus scharf|lesen [--jetzt ISO] [--projekt PFAD] [--vault PFAD] -Exit: 0 ohne Befund, 1 mit Befund, 2 Bedienfehler. -""" - -import argparse -import json -import os -import re -import subprocess -import sys -import time -import traceback -from datetime import datetime -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from abendkontrolle import KEIN_FENSTER # noqa: E402 -from abendkontrolle import WSL # noqa: E402 -from abendkontrolle import WSL_PY # noqa: E402 -from abendkontrolle import WSL_SKRIPT # noqa: E402 -from abendkontrolle import schreibe_notiz # noqa: E402 - -VAULT = Path(r"D:\Claude") -PROJEKT = VAULT / "Code Projekte" / "active - Trading Plattform" -ABLAGE_TEIL = Path("data") / "paper" / "abbau" -ERGEBNIS_NAME = "abbau.json" -LOG_NAME = "abbau.log" -# Entscheidung 4 der Anforderung, Berliner Ortszeit, beide Grenzen eingeschlossen. -FRUEHESTENS = datetime(2026, 10, 8, 15, 40) -SPAETESTENS = datetime(2026, 10, 9, 21, 45) -MIN_BIS_SCHLUSS_S = 600 -WARTEN_S = 30 -SCHARF = "scharf" -LESEN = "lesen" -MODI = (SCHARF, LESEN) -NICHTS = "nichts" -STILL = "still" -LESEN_ENDE = "lesen_ende" -BERICHT = "bericht" -INSTANZEN = ("alpaca", "ibkr") -UNITS = {"alpaca": "trading-paper.service", "ibkr": "trading-paper-ibkr.service"} -# Die Instanzordner in WSL. Nur dieses Skript nennt sie; die WSL-Teile bekommen sie als Argument. -ORDNER_WSL = { - "alpaca": "/mnt/d/Claude/Code Projekte/active - Trading Plattform", - "ibkr": "/mnt/d/Claude/.arbeitsbaeume/trading-ibkr-betrieb", -} -HIER_WSL = "/mnt/d/Claude/.vault-tools/trading-plattform-pruefung" -ALPACA_PY = "/home/claude/venvs/trading/bin/python" -ALPACA_LESEN = HIER_WSL + "/abbau_alpaca_lesen.py" -IBKR_LESEN = HIER_WSL + "/abbau_ibkr_lesen.sh" -GLATTSTELLEN = HIER_WSL + "/abbau_glattstellen.sh" -RUNNER_LAEUFT = ("active", "activating", "deactivating", "reloading", "refreshing") -RUNNER_STEHT = ("inactive", "failed") -RUNNER_TIMEOUT_S = 60 -LESEN_TIMEOUT_S = 180 -# killswitch.py: Gateway-Zeitlimit 10 s je Anfrage, bis zu drei Sammelversuche, 20 s Positionsfrist. -GLATT_TIMEOUT_S = 600 -MELDUNG_TIMEOUT_S = 120 -ZEILEN_MAX = 12 -ZEILE_MAX = 200 -# CREATE_NO_WINDOW gibt es nur unter Windows; die Angriffsfaelle laufen auch in WSL. -FENSTERLOS = KEIN_FENSTER if sys.platform == "win32" else 0 -NUL = chr(0) -# Die Ausgabe von scripts/ibkr_pruefen.py lesen, gemessen am 2026-10-07 gegen 22:56 (Anforderung). -IBKR_POSITIONEN = re.compile(r"^Positionen: (\d+)\s*$") -IBKR_ORDERS = re.compile(r"^Offene Orders: (\d+)\s*$") -IBKR_ZEILE = re.compile(r"^ (\S+) (-?\d+)(?:\.0+)?\s*$") -IBKR_GELESEN = "ERGEBNIS: gelesen" -TITEL = "Abbau Restbestand" -KOPF = "Abbau Restbestand (automatisch, Auftrag Pascal 2026-10-07)" -GESPERRT_HINWEIS = "Der Killswitch jeder angefassten Instanz steht danach auf gesperrt; Freigeben bleibt Pascal." - -# Die Angriffsfaelle ersetzen schlafe und starte, damit kein Fall wartet und keiner einen Prozess startet. -schlafe = time.sleep -starte = subprocess.run - - -def sauber(text: str) -> str: - """Text ohne NUL: wsl.exe schreibt eigene Fehler in UTF-16LE, und ein NUL im Argument laesst subprocess scheitern.""" - return text.replace(NUL, "") - - -def einzeilig(text: str) -> str: - """Ohne NUL und ohne Zeilenumbruch: Ein fremder Text bleibt eine Zeile in Ausgabe, Log, Notiz und Meldung.""" - return sauber(text).replace("\r", " ").replace("\n", " ") - - -def wsl(argumente: list[str], timeout: int) -> tuple[int, str, str]: - """Ein Aufruf in WSL ohne Shell (--exec): Exit, stdout, stderr. Der einzige Weg nach draussen.""" - lauf = starte( - [WSL, "-d", "Debian", "-u", "claude", "--exec", *argumente], - capture_output=True, timeout=timeout, creationflags=FENSTERLOS, - env={**os.environ, "WSL_UTF8": "1"}, - ) - return ( - lauf.returncode, - sauber(lauf.stdout.decode("utf-8", errors="replace")), - sauber(lauf.stderr.decode("utf-8", errors="replace")), - ) - - -def lies_runner(instanz: str) -> str: - """LoadState und ActiveState der Runner-Unit (systemctl show); liest nur.""" - _, ausgabe, _ = wsl(["systemctl", "show", "--property=LoadState,ActiveState", UNITS[instanz]], RUNNER_TIMEOUT_S) - return ausgabe - - -def runner_felder(ausgabe: str) -> tuple[str, str]: - """LoadState und ActiveState aus der Ausgabe von systemctl show; was fehlt, ist leer.""" - felder = {} - for zeile in ausgabe.splitlines(): - name, _, wert = zeile.partition("=") - felder[name.strip()] = wert.strip() - return felder.get("LoadState", ""), felder.get("ActiveState", "") - - -def runner_urteil(laden: str, aktiv: str) -> str | None: - """laeuft, steht, oder None (fail-closed). steht nur bei einer geladenen Unit, die inactive oder failed ist.""" - if aktiv in RUNNER_LAEUFT: - return "laeuft" - if laden == "loaded" and aktiv in RUNNER_STEHT: - return "steht" - return None - - -def lies_alpaca() -> dict: - """Die JSON-Zeile von abbau_alpaca_lesen.py; ohne sie steht der Grund in beiden Fehlerfeldern.""" - try: - code, ausgabe, _ = wsl([ALPACA_PY, ALPACA_LESEN, "--projekt", ORDNER_WSL["alpaca"]], LESEN_TIMEOUT_S) - except Exception as ausnahme: - code, ausgabe = f"nicht gestartet, {type(ausnahme).__name__}", "" - for zeile in reversed(ausgabe.splitlines()): - zeile = zeile.strip() - if not zeile.startswith("{"): - continue - try: - daten = json.loads(zeile) - except ValueError: - break - if isinstance(daten, dict): - return daten - break - grund = f"abbau_alpaca_lesen.py ohne JSON-Zeile (Exit {code})" - return {"uhr_fehler": grund, "bestand_fehler": grund} - - -def uhr_lesbar(uhr) -> bool: - """Die Marktuhr in der Form von abbau_alpaca_lesen.py: offen als bool, Sekunden als ganze Zahl.""" - return ( - isinstance(uhr, dict) - and type(uhr.get("offen")) is bool - and type(uhr.get("sekunden_bis_schluss")) is int - ) - - -def alpaca_bestand(daten: dict) -> dict | None: - """Symbol -> Stueck und die Zahl offener Orders aus der JSON-Zeile, oder None, wenn nicht gelesen.""" - positionen, orders = daten.get("positionen"), daten.get("orders") - if not isinstance(positionen, list) or type(orders) is not int: - return None - stueck: dict[str, int] = {} - for eintrag in positionen: - if not isinstance(eintrag, dict) or not isinstance(eintrag.get("symbol"), str): - return None - if type(eintrag.get("menge")) is not int: - return None - stueck[eintrag["symbol"]] = stueck.get(eintrag["symbol"], 0) + eintrag["menge"] - return {"positionen": stueck, "orders": orders} - - -def ibkr_bestand(code: int, ausgabe: str) -> dict | None: - """Positionen und offene Orders aus stdout von scripts/ibkr_pruefen.py lesen, oder None. - - Gelesen ist der Bestand nur mit Exit 0 oder 1 (1: Befund zur Kontogroesse, die Liste steht - trotzdem da), mit der Zeile "ERGEBNIS: gelesen", beiden Zahlenzeilen und genau so vielen - Positionszeilen, wie "Positionen: n" sagt. - """ - zeilen = ausgabe.splitlines() - if not any(zeile.startswith(IBKR_GELESEN) for zeile in zeilen): - return None - if code not in (0, 1): - return None - anzahl = orders = None - gezaehlt = 0 - stueck: dict[str, int] = {} - im_block = False - for zeile in zeilen: - treffer = IBKR_POSITIONEN.match(zeile) - if treffer: - anzahl, im_block = int(treffer.group(1)), True - continue - treffer = IBKR_ORDERS.match(zeile) - if treffer: - orders, im_block = int(treffer.group(1)), False - continue - if im_block: - treffer = IBKR_ZEILE.match(zeile) - if treffer is None: - return None - symbol = treffer.group(1).split(".")[0] - stueck[symbol] = stueck.get(symbol, 0) + int(treffer.group(2)) - gezaehlt += 1 - if anzahl is None or orders is None or gezaehlt != anzahl: - return None - return {"positionen": stueck, "orders": orders} - - -def lies_ibkr() -> tuple[dict | None, int | None]: - """Bestand der IBKR-Instanz ueber abbau_ibkr_lesen.sh (ibkr_pruefen.py lesen) und dessen Exit.""" - try: - code, ausgabe, _ = wsl(["bash", IBKR_LESEN, ORDNER_WSL["ibkr"]], LESEN_TIMEOUT_S) - except Exception: - return None, None - return ibkr_bestand(code, ausgabe), code - - -def hat_bestand(bestand: dict | None) -> bool: - """Position ungleich null oder offene Order. Ein nicht gelesener Bestand (None) hat keinen: fail-closed.""" - return bestand is not None and (any(menge != 0 for menge in bestand["positionen"].values()) or bestand["orders"] > 0) - - -def ist_flach(bestand: dict | None) -> bool: - """Gelesen, keine Position ungleich null und keine offene Order.""" - return bestand is not None and not any(menge != 0 for menge in bestand["positionen"].values()) and bestand["orders"] == 0 - - -def flach_markiert(datei: Path) -> bool: - """Ob eine fruehere Ergebnisdatei "flach": true traegt; unlesbar oder anders heisst nein.""" - try: - daten = json.loads(datei.read_text(encoding="utf-8")) - except (OSError, ValueError): - return False - return isinstance(daten, dict) and daten.get("flach") is True - - -def beschreibe(bestand: dict | None) -> str: - """Ein Bestand fuer Ausgabe, Notiz und Meldung: Symbol, Richtung, Stueck, offene Orders. Nie ein Betrag.""" - if bestand is None: - return "nicht gelesen" - teile = [ - f"{symbol} {'long' if menge > 0 else 'short'} {abs(menge)}" - for symbol, menge in sorted(bestand["positionen"].items()) - if menge != 0 - ] - liste = ": " + ", ".join(teile) if teile else "" - return f"{len(teile)} Position(en){liste}; offene Orders {bestand['orders']}" - - -def glattstelle(instanz: str, modus: str) -> tuple[int, list[str]]: - """Genau ein Aufruf von abbau_glattstellen.sh fuer eine Instanz; nur im Modus scharf. - - Die Zeilen kommen aus WSL schon gefiltert (Wort, Kontonummern, Betraege); hier werden sie nur gekuerzt. - """ - if modus != SCHARF: - raise RuntimeError("glattstellen nur im Modus scharf") - code, ausgabe, fehler = wsl(["bash", GLATTSTELLEN, instanz, ORDNER_WSL[instanz]], GLATT_TIMEOUT_S) - zeilen = [zeile.strip()[:ZEILE_MAX] for zeile in (ausgabe + "\n" + fehler).splitlines() if zeile.strip()] - return code, zeilen[-ZEILEN_MAX:] - - -def melde(titel: str, text: str, modus: str) -> str: - """Eine Meldung mit hoher Prioritaet ueber abendkontrolle_wsl.py --meldung; nur im Modus scharf.""" - if modus != SCHARF: - return "nicht gesendet: Modus lesen" - titel, text = sauber(titel), sauber(text) - try: - code, ausgabe, _ = wsl([WSL_PY, WSL_SKRIPT, "--meldung", titel, text], MELDUNG_TIMEOUT_S) - except Exception as ausnahme: - return f"nicht gesendet: {type(ausnahme).__name__}" - for zeile in reversed(ausgabe.splitlines()): - zeile = zeile.strip() - if zeile.startswith("{"): - try: - return str(json.loads(zeile).get("meldung"))[:200] - except (ValueError, AttributeError): - break - return f"nicht gesendet: keine Antwort (Exit {code})" - - -def logge(ablage: Path, text: str) -> None: - """Eine Zeile in abbau.log; ein Fehler dabei haelt den Lauf nicht auf.""" - try: - ablage.mkdir(parents=True, exist_ok=True) - with (ablage / LOG_NAME).open("a", encoding="utf-8", newline="\n") as datei: - datei.write(f"{datetime.now().isoformat(timespec='seconds')} {text}\n") - except OSError: - pass - - -def schreibe_ergebnis(ablage: Path, daten: dict) -> None: - """abbau.json ueber eine Nachbardatei und os.replace: nie eine halbe Datei.""" - ablage.mkdir(parents=True, exist_ok=True) - neu = ablage / (ERGEBNIS_NAME + ".tmp") - neu.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8", newline="\n") - os.replace(neu, ablage / ERGEBNIS_NAME) - - -class Lauf: - """Was ein Lauf gesehen und getan hat; daraus entstehen Ausgabe, Ergebnisdatei, Notiz und Meldung.""" - - def __init__(self, jetzt: datetime, modus: str, ablage: Path) -> None: - self.jetzt = jetzt - self.modus = modus - self.ablage = ablage - self.ausgabe: list[str] = [] - self.befunde: list[str] = [] - self.gesperrt: set[str] = set() - self.markt = "" - self.vorher: dict = {} - self.nachher: dict = {} - self.eingriffe: dict = {} - self.spur = "" - - def sage(self, text: str) -> None: - self.ausgabe.append(einzeilig(text)) - - def befund(self, text: str) -> None: - self.befunde.append(einzeilig(text)[:300]) - - def zusammenfassung(self) -> list[str]: - """Je Instanz eine Zeile: vorher, was geschah, nachher.""" - zeilen = [] - for instanz in INSTANZEN: - eingriff = self.eingriffe.get(instanz) - tat = "nicht angefasst" if eingriff is None else f"glattstellen Exit {eingriff['exit']}" - zeilen.append( - f"{instanz}: vorher {beschreibe(self.vorher.get(instanz))}; {tat}; " - f"nachher {beschreibe(self.nachher.get(instanz))}" - ) - return zeilen - - -def fahre(lauf: Lauf) -> str: - """Schritte 1 bis 6; gibt NICHTS, STILL, LESEN_ENDE oder BERICHT zurueck.""" - scharf = lauf.modus == SCHARF - if not scharf: - lauf.sage("Modus lesen: kein Glattstellen, keine Meldung, keine Notiz, keine Datei") - # 1 Fenster und Ergebnisdatei - im_fenster = FRUEHESTENS <= lauf.jetzt <= SPAETESTENS - lage = "innerhalb" if im_fenster else "ausserhalb" - lauf.sage(f"Fenster {FRUEHESTENS:%Y-%m-%d %H:%M} bis {SPAETESTENS:%Y-%m-%d %H:%M}: {lage}") - if scharf and not im_fenster: - return NICHTS - if flach_markiert(lauf.ablage / ERGEBNIS_NAME): - lauf.sage("Ergebnisdatei sagt flach") - if scharf: - return STILL - - # 2 Runner-Units: ein laufender, nicht geladener oder unlesbarer Runner sperrt seine Instanz - for instanz in INSTANZEN: - try: - laden, aktiv = runner_felder(lies_runner(instanz)) - except Exception as ausnahme: - laden, aktiv = "", f"nicht lesbar, {type(ausnahme).__name__}" - zustand = f"LoadState={laden or 'leer'}, ActiveState={aktiv or 'leer'}" - lauf.sage(f"Runner {instanz} ({UNITS[instanz]}): {zustand}") - urteil = runner_urteil(laden, aktiv) - if urteil != "steht": - lauf.gesperrt.add(instanz) - grund = "laeuft" if urteil == "laeuft" else "Zustand nicht lesbar" - lauf.befund(f"{instanz}: Runner-Unit {UNITS[instanz]} {grund} ({zustand[:80]}); nicht angefasst") - - # 3 Marktuhr: zu oder bald zu heisst im Modus scharf still zu Ende - alpaca = lies_alpaca() - uhr = alpaca.get("uhr") - if not uhr_lesbar(uhr): - lauf.gesperrt.update(INSTANZEN) - lauf.markt = "Marktuhr nicht lesbar" - lauf.befund(f"Marktuhr nicht lesbar ({alpaca.get('uhr_fehler') or 'ohne Angabe'}); nichts angefasst") - else: - if uhr["offen"]: - lauf.sage(f"Markt offen, {uhr['sekunden_bis_schluss'] // 60} min bis Schluss") - else: - lauf.sage("Markt zu") - handelbar = uhr["offen"] and uhr["sekunden_bis_schluss"] >= MIN_BIS_SCHLUSS_S - if not handelbar: - lauf.markt = "Markt zu oder weniger als 10 min bis Schluss" - if scharf: - return STILL - - # 4 Bestaende: nicht lesbar heisst Befund; hat_bestand(None) ist falsch, die Instanz bleibt unberuehrt - lauf.vorher["alpaca"] = alpaca_bestand(alpaca) - if lauf.vorher["alpaca"] is None: - lauf.befund(f"alpaca: Bestand nicht lesbar ({alpaca.get('bestand_fehler') or 'ohne Angabe'}); nicht angefasst") - lauf.vorher["ibkr"], ibkr_exit = lies_ibkr() - if lauf.vorher["ibkr"] is None: - lauf.befund(f"ibkr: Bestand nicht lesbar (ibkr_pruefen.py lesen, Exit {ibkr_exit}); nicht angefasst") - for instanz in INSTANZEN: - lauf.sage(f"Bestand {instanz}: {beschreibe(lauf.vorher[instanz])}") - - # 5 Je Instanz hoechstens ein Eingriff, nur im Modus scharf; wirft er, gilt Exit None - zu_glatt = [instanz for instanz in INSTANZEN if instanz not in lauf.gesperrt and hat_bestand(lauf.vorher[instanz])] - if lauf.modus == LESEN: - entscheidung = f"keine ({lauf.markt})" if lauf.markt else (", ".join(zu_glatt) or "keine") - lauf.sage(f"Glattstellen im Modus scharf: {entscheidung}") - return LESEN_ENDE - for instanz in zu_glatt: - try: - ergebnis_code, zeilen = glattstelle(instanz, lauf.modus) - except Exception as ausnahme: - ergebnis_code, zeilen = None, [f"abgebrochen: {type(ausnahme).__name__}"] - lauf.eingriffe[instanz] = {"exit": ergebnis_code, "zeilen": zeilen} - if ergebnis_code != 0: - lauf.befund(f"{instanz}: glattstellen endete mit Exit {ergebnis_code}") - - # 6 Nach einem Eingriff warten und beide Bestaende neu lesen - if lauf.eingriffe: - schlafe(WARTEN_S) - lauf.nachher["alpaca"] = alpaca_bestand(lies_alpaca()) - lauf.nachher["ibkr"], _ = lies_ibkr() - for instanz in INSTANZEN: - if lauf.nachher[instanz] is None: - lauf.befund(f"{instanz}: Bestand nachher nicht lesbar; Ergebnis offen") - else: - lauf.nachher = dict(lauf.vorher) - return BERICHT - - -def ergebnis_daten(lauf: Lauf, flach: bool) -> dict: - """Inhalt von abbau.json: je Instanz vorher, nachher und das Glattstellen; dazu flach und die Befunde.""" - return { - "zeit": lauf.jetzt.isoformat(timespec="seconds"), - "flach": flach, - "befunde": lauf.befunde, - "instanzen": { - instanz: { - "vorher": lauf.vorher.get(instanz), - "nachher": lauf.nachher.get(instanz), - "glattstellen": lauf.eingriffe.get(instanz), - } - for instanz in INSTANZEN - }, - } - - -def titel_fuer(lauf: Lauf, flach: bool) -> str: - """Rangfolge: flach mit Befund, Befund, flach, Rest offen.""" - if flach and lauf.befunde: - return f"{TITEL}: flach, mit Befund" - if lauf.befunde: - return f"{TITEL}: Befund" - if flach: - return f"{TITEL}: flach" - return f"{TITEL}: Rest offen" - - -def notizzeile(lauf: Lauf, flach: bool, gemeldet: str) -> str: - """Die Zeile unter "## Agentenprotokoll", mit Unterpunkten. Nur Symbol, Richtung, Stueck, Zahlen.""" - if flach and lauf.befunde: - urteil = "flach, mit Befund #needs-review" - elif lauf.befunde: - urteil = "**Befund** #needs-review" - elif flach: - urteil = "flach" - else: - urteil = "Rest offen" - zeilen = [f"- {lauf.jetzt:%H:%M} Uhr - {KOPF}: {urteil}."] - zeilen += [f" - {teil}" for teil in lauf.zusammenfassung()] - for instanz, eingriff in lauf.eingriffe.items(): - if eingriff["exit"] != 0: - zeilen += [f" - {instanz}, killswitch.py: {text}" for text in eingriff["zeilen"][-5:]] - zeilen += [f" - Befund: {befund}" for befund in lauf.befunde] - if lauf.eingriffe: - zeilen.append(f" - {GESPERRT_HINWEIS}") - zeilen.append(f" - Meldung ans Telefon: {gemeldet}") - return "\n".join(zeilen) - - -def berichte(lauf: Lauf, vault: Path) -> int: - """Schritte 7 und 8: Ergebnisdatei und Log; Meldung und Notizzeile bei Eingriff, Befund oder flach.""" - # flach nur ohne gesperrte Instanz: Ein laufender oder unlesbarer Runner oder eine unlesbare - # Marktuhr kann den Bestand gleich wieder aendern; der naechste Termin beobachtet weiter. - flach = not lauf.gesperrt and all(ist_flach(lauf.nachher.get(instanz)) for instanz in INSTANZEN) - try: - schreibe_ergebnis(lauf.ablage, ergebnis_daten(lauf, flach)) - except OSError as ausnahme: - lauf.befund(f"Ergebnisdatei nicht geschrieben ({type(ausnahme).__name__})") - logge(lauf.ablage, f"scharf: {' | '.join(lauf.ausgabe + lauf.zusammenfassung() + lauf.befunde)} | flach={flach}") - if lauf.spur: - logge(lauf.ablage, lauf.spur) - code = 1 if lauf.befunde else 0 - # Still nur ohne Eingriff, ohne Befund und ohne flach. Das erste flach ist die Entwarnung; - # Laeufe nach der Marke enden schon in fahre(). - if not lauf.eingriffe and not lauf.befunde and not flach: - return code - titel = titel_fuer(lauf, flach) - text = "; ".join(lauf.zusammenfassung() + [f"Befund: {befund}" for befund in lauf.befunde]) - gemeldet = melde(titel, text[:1500].replace('"', "'"), lauf.modus) - try: - schreibe_notiz(vault, lauf.jetzt.date().isoformat(), notizzeile(lauf, flach, gemeldet)) - except Exception as ausnahme: - code = 1 - logge(lauf.ablage, f"Notiz nicht geschrieben: {type(ausnahme).__name__}") - return code - - -def main(argv=None) -> int: - zerleger = argparse.ArgumentParser(description=__doc__.splitlines()[0], allow_abbrev=False) - zerleger.add_argument( - "--modus", choices=MODI, required=True, - help="lesen: nur lesende Aufrufe, schreibt nichts; scharf: alles, nur ueber die Windows-Aufgabe", - ) - zerleger.add_argument("--jetzt", help="ISO-Zeitpunkt statt der Uhr (Angriffsfaelle)") - zerleger.add_argument("--projekt", default=str(PROJEKT), help="Projektordner unter Windows, fuer die Ablage") - zerleger.add_argument("--vault", default=str(VAULT)) - argumente = zerleger.parse_args(argv) - try: - jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now() - except ValueError: - zerleger.error("--jetzt ist kein ISO-Zeitpunkt") - lauf = Lauf(jetzt, argumente.modus, Path(argumente.projekt) / ABLAGE_TEIL) - try: - ende = fahre(lauf) - except Exception as ausnahme: - # Nur der Typ: Der Text einer fremden Ausnahme kann Kontonummer oder Betrag tragen. - # Der Traceback geht nur nach abbau.log (data/, nicht veroeffentlicht). - lauf.befund(f"abgebrochen: {type(ausnahme).__name__}") - lauf.spur = traceback.format_exc() - ende = BERICHT if lauf.modus == SCHARF else LESEN_ENDE - if ende == NICHTS: - return 0 - if ende == STILL: - logge(lauf.ablage, f"scharf, nichts angefasst: {' | '.join(lauf.ausgabe)}") - return 0 - if ende == LESEN_ENDE: - for zeile in lauf.ausgabe: - print(zeile) - for befund in lauf.befunde: - print(f"BEFUND: {befund}") - print(f"ERGEBNIS: gelesen, {'mit' if lauf.befunde else 'ohne'} Befund") - return 1 if lauf.befunde else 0 - return berichte(lauf, Path(argumente.vault)) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/abbau_restbestand_angriffe.py b/.vault-tools/trading-plattform-pruefung/abbau_restbestand_angriffe.py deleted file mode 100644 index 004aa3a..0000000 --- a/.vault-tools/trading-plattform-pruefung/abbau_restbestand_angriffe.py +++ /dev/null @@ -1,817 +0,0 @@ -"""Angriffsfaelle fuer den Abbau des Restbestands (Paket AB): abbau_restbestand.py und seine WSL-Teile. - -Ersteller: Pascal Michel -Erstellt: 2026-10-07 -Doku: LIESMICH.md in diesem Ordner, Abschnitt "Paket AB" - -Koordinator-Werkzeug, nicht fuers Repo. Jeder Fall ist eine Funktion test_; so laufen -dieselben Faelle als Skript und unter pytest (mutanten_lauf.py mit mutanten.toml im Planordner). -Abdichtung, doppelt: Alle Pfade nach draussen zeigen unten ins Leere (wsl.exe, die WSL-Skripte, die -Instanzordner), und jeder Fall ersetzt wsl() durch die Attrappe Welt, die jeden Aufruf zaehlt und nichts -startet; der Fall zu wsl() selbst ersetzt starte (subprocess.run). Kein Fall erreicht wsl.exe, das Netz -oder ntfy. Die Attrappe lehnt wie CPython jedes Argument mit NUL ab. -Die Faelle test_wsl_* fahren bash und die Venvs in WSL gegen Wegwerf-Instanzordner mit einem falschen -scripts/killswitch.py, dem erfundenen Wort TESTWORT-NICHT-ECHT, der erfundenen Kontonummer DUQ123456 und -erfundenen Betraegen, nie gegen einen echten Instanzordner. Unter Windows sind sie uebersprungen. -Aufruf unter Windows: python -I -X utf8 abbau_restbestand_angriffe.py -Aufruf in WSL: /home/claude/venvs/trading/bin/python -I abbau_restbestand_angriffe.py -Exit 0, wenn jeder Fall wie erwartet ausgeht. -""" - -import contextlib -import io -import json -import subprocess -import sys -import tempfile -from collections import namedtuple -from datetime import datetime -from datetime import timedelta -from datetime import timezone -from pathlib import Path -from types import SimpleNamespace - -HIER = Path(__file__).resolve().parent -sys.path.insert(0, str(HIER)) -import abbau_restbestand as ab # noqa: E402 - -# Abdichtung (Vorfall 2026-09-26: ein Mutant schickte ueber den absoluten WSL_SKRIPT Meldungen ans -# Telefon). Jeder Pfad nach draussen zeigt ins Leere, bevor ein Fall laeuft. -ab.WSL = str(Path(tempfile.gettempdir()) / "abbau-angriffe-kein-wsl" / "wsl.exe") -ab.WSL_SKRIPT = "/nicht-vorhanden/abendkontrolle_wsl.py" -ab.ALPACA_LESEN = "/nicht-vorhanden/abbau_alpaca_lesen.py" -ab.IBKR_LESEN = "/nicht-vorhanden/abbau_ibkr_lesen.sh" -ab.GLATTSTELLEN = "/nicht-vorhanden/abbau_glattstellen.sh" -ab.ORDNER_WSL = {"alpaca": "/nicht-vorhanden/alpaca", "ibkr": "/nicht-vorhanden/ibkr"} - -WRAPPER = HIER / "abbau_glattstellen.sh" -IBKR_SKRIPT = HIER / "abbau_ibkr_lesen.sh" -NUL = chr(0) -TESTWORT = "TESTWORT-NICHT-ECHT" -KONTO = "DUQ123456" -BETRAG = "12345.67" -BETRAG_OHNE_GELDWORT = "98.76" -JETZT = "2026-10-08T15:45:00" -GRUND = ( - "Abbau Restbestand nach Netzschalter-Abriss 2026-10-07; Auftrag Pascal 2026-10-07, " - "ausgefuehrt von der Aufgabe Abbau Restbestand" -) -LOG = "projekt/data/paper/abbau/abbau.log" -ERGEBNIS = "projekt/data/paper/abbau/abbau.json" - -OFFEN = {"offen": True, "sekunden_bis_schluss": 6 * 3600} -ZU = {"offen": False, "sekunden_bis_schluss": 18 * 3600} -FLACH = {"positionen": [], "orders": 0} -AAPL10 = {"positionen": [("AAPL", 10)], "orders": 0} -IBKR7 = { - "positionen": [ - ("AAPL.XNAS", 10), ("MSFT.XNAS", 10), ("NVDA.XNAS", 20), ("JPM.XNYS", 10), - ("CAT.XNYS", 10), ("KO.XNYS", 20), ("NEE.XNYS", 20), - ], - "orders": 0, -} -WRAPPER_AUSGABE = ( - "Zustand vorher: angehalten\nBroker: alpaca\nErgebnis: glattstellen, erfolgreich=True\n" - "Zustand nachher: gesperrt\n" -) -# Ein falsches scripts/killswitch.py fuer die Faelle test_wsl_*: schreibt seine Argumente (mit dem Wort) -# in den Wegwerf-Ordner, druckt das Wort auf stderr und eine erfundene Kontonummer und erfundene Betraege -# auf stdout. Nichts davon darf aus dem Wrapper herauskommen. -FALSCHER_KILLSWITCH = """import sys -from pathlib import Path -Path("aufgerufen").write_text(" ".join(sys.argv[1:]), encoding="utf-8") -print("Zustand vorher: aktiv") -print("Interpreter: " + sys.executable) -print("Argumente: " + " ".join(sys.argv[1:])) -print("Ergebnis: glattstellen, erfolgreich=False") -print(" Fehler: Kontowert 12345.67 USD nicht gedeckt") -print(" Fehler: Order an DUQ123456 abgelehnt, limit 98.76") -print("Zustand nachher: gesperrt", flush=True) -print("Abgelehnt, es wurde nichts geaendert: " + sys.argv[-1], file=sys.stderr) -sys.exit(int(Path("exit.txt").read_text(encoding="utf-8"))) -""" - -Roh = namedtuple("Roh", "exit text") - - -class Uebersprungen(Exception): - """Ein Fall, der bash und die Venvs in WSL braucht, unter Windows.""" - - -def nur_in_wsl() -> None: - if sys.platform == "win32": - raise Uebersprungen("nur in WSL") - - -def alpaca_json(uhr, bestand, fehler="BrokerFehler: Test") -> str: - """Die JSON-Zeile, die abbau_alpaca_lesen.py drucken wuerde; fehler ist der bestand_fehler.""" - daten = {"uhr": uhr, "uhr_fehler": None, "positionen": None, "orders": None, "bestand_fehler": None} - if uhr is None: - daten["uhr_fehler"] = "BrokerFehler: Marktuhr nicht lesbar" - if bestand is None: - daten["bestand_fehler"] = fehler - else: - daten["positionen"] = [{"symbol": symbol, "menge": menge} for symbol, menge in bestand["positionen"]] - daten["orders"] = bestand["orders"] - return json.dumps(daten) + "\n" - - -def ibkr_text(bestand, schluss="ERGEBNIS: gelesen") -> str: - """stdout von scripts/ibkr_pruefen.py lesen in der gemessenen Form, ohne Kontonummer.""" - zeilen = [ - "verbunden: Gateway 127.0.0.1:4004, Client-ID 12, Handelsmodus paper, Konto XX (8 Zeichen), bestaetigt", - "Konto: Kontowert, Bargeld und Kaufkraft gelesen (Betraege werden nicht ausgegeben)", - f"Positionen: {len(bestand['positionen'])}", - ] - zeilen += [f" {kennung} {menge}" for kennung, menge in bestand["positionen"]] - zeilen += [f"Offene Orders: {bestand['orders']}", schluss] - return "\n".join(zeilen) + "\n" - - -def unit_text(stand) -> str: - """Ausgabe von systemctl show --property=LoadState,ActiveState: Text als Roh, (LoadState, ActiveState) - als Paar, oder nur ActiveState einer geladenen Unit.""" - if isinstance(stand, Roh): - return stand.text - laden, aktiv = stand if isinstance(stand, tuple) else ("loaded", stand) - return f"LoadState={laden}\nActiveState={aktiv}\n" - - -class Welt: - """Attrappe fuer alles hinter ab.wsl(): Units, Bestaende, Wrapper, Meldung. Zaehlt jeden Aufruf.""" - - def __init__(self, *, units=("inactive", "inactive"), uhr=OFFEN, alpaca=AAPL10, ibkr=IBKR7, - alpaca_nachher=FLACH, ibkr_nachher=FLACH, alpaca_fehler="BrokerFehler: Test", - wrapper_exit=0, wrapper_wirft=False, echter_wrapper=None): - self.units = dict(zip(ab.INSTANZEN, units)) - self.uhr = uhr - self.bestand = {"alpaca": alpaca, "ibkr": ibkr} - self.nachher = {"alpaca": alpaca_nachher, "ibkr": ibkr_nachher} - self.alpaca_fehler = alpaca_fehler - self.wrapper_exit = wrapper_exit - self.wrapper_wirft = wrapper_wirft - self.echter_wrapper = echter_wrapper - self.aufrufe = [] - self.glatt = [] - self.ordner = [] - self.meldungen = [] - self.schlaf = [] - self.gelesen = {"alpaca": 0, "ibkr": 0} - - def schlafe(self, sekunden): - self.schlaf.append(sekunden) - - def __call__(self, argumente, timeout): - argumente = list(argumente) - if any(NUL in argument for argument in argumente): - raise ValueError("embedded null character") - self.aufrufe.append(argumente) - if argumente[:2] == ["systemctl", "show"]: - instanz = next(name for name, unit in ab.UNITS.items() if unit == argumente[-1]) - return 0, unit_text(self.units[instanz]), "" - if argumente[:2] == [ab.ALPACA_PY, ab.ALPACA_LESEN]: - self.gelesen["alpaca"] += 1 - return 0, alpaca_json(self.uhr, self.bestand["alpaca"], self.alpaca_fehler), "" - if argumente[:2] == ["bash", ab.IBKR_LESEN]: - self.gelesen["ibkr"] += 1 - stand = self.bestand["ibkr"] - if stand is None: - return 4, "", "Voraussetzung fehlt: Gateway nicht erreichbar\n" - if isinstance(stand, Roh): - return stand.exit, stand.text, "" - return 0, ibkr_text(stand), "" - if argumente[:2] == ["bash", ab.GLATTSTELLEN]: - instanz = argumente[2] - self.glatt.append(instanz) - self.ordner.append(argumente[3]) - if self.wrapper_wirft: - raise subprocess.TimeoutExpired(argumente, timeout) - self.bestand[instanz] = self.nachher[instanz] - if self.echter_wrapper is not None: - return self.echter_wrapper(argumente[2:]) - return self.wrapper_exit, WRAPPER_AUSGABE, "" - if argumente[:3] == [ab.WSL_PY, ab.WSL_SKRIPT, "--meldung"]: - self.meldungen.append((argumente[3], argumente[4])) - return 0, '{"meldung": "gesendet"}\n', "" - raise AssertionError(f"unbekannter Aufruf: {argumente!r}") - - -@contextlib.contextmanager -def attrappe(welt): - """Ersetzt wsl() und schlafe() fuer die Dauer eines Falls und stellt beide danach zurueck.""" - alt = ab.wsl, ab.schlafe - ab.wsl, ab.schlafe = welt, welt.schlafe - try: - yield welt - finally: - ab.wsl, ab.schlafe = alt - - -def lies_json(text): - try: - return json.loads(text) if text else None - except ValueError: - return None - - -def lauf_mit(welt, *, jetzt=JETZT, modus="scharf", ergebnis_vorher=None): - """Ein Lauf von ab.main() in Wegwerf-Ordnern fuer Projekt und Vault; gibt alles Sichtbare zurueck.""" - with tempfile.TemporaryDirectory() as tmp: - tmp = Path(tmp) - projekt, vault = tmp / "projekt", tmp / "vault" - projekt.mkdir() - (vault / "Inbox").mkdir(parents=True) - ablage = projekt / ab.ABLAGE_TEIL - if ergebnis_vorher is not None: - ablage.mkdir(parents=True) - (ablage / ab.ERGEBNIS_NAME).write_text(ergebnis_vorher, encoding="utf-8") - puffer = io.StringIO() - with attrappe(welt), contextlib.redirect_stdout(puffer): - code = ab.main(["--modus", modus, "--jetzt", jetzt, "--projekt", str(projekt), "--vault", str(vault)]) - ergebnis = ablage / ab.ERGEBNIS_NAME - log = ablage / ab.LOG_NAME - notizen = sorted((vault / "Inbox").glob("*.md")) - text = ergebnis.read_text(encoding="utf-8") if ergebnis.exists() else None - return SimpleNamespace( - code=code, - welt=welt, - ausgabe=puffer.getvalue(), - dateien=sorted(p.relative_to(tmp).as_posix() for p in tmp.rglob("*") if p.is_file()), - ergebnis_text=text, - json=lies_json(text), - log=log.read_text(encoding="utf-8") if log.exists() else "", - notiz=notizen[0].read_text(encoding="utf-8") if notizen else "", - ) - - -def titel_von(e) -> list[str]: - return [titel for titel, _ in e.welt.meldungen] - - -def kurz(e) -> str: - flach = None if e.json is None else e.json.get("flach") - return ( - f"code={e.code} glatt={e.welt.glatt} ordner={e.welt.ordner} schlaf={e.welt.schlaf} " - f"gelesen={e.welt.gelesen} meldungen={titel_von(e)} flach={flach} dateien={e.dateien} " - f"ausgabe={e.ausgabe[-400:]!r}" - ) - - -def instanzordner(pfad: Path, *, wort=TESTWORT, exit_code=0) -> Path: - """Ein Wegwerf-Instanzordner mit falschem scripts/killswitch.py und, wenn wort gesetzt ist, killswitch.toml.""" - (pfad / "scripts").mkdir(parents=True) - (pfad / "config").mkdir() - if wort is not None: - (pfad / "config" / "killswitch.toml").write_text( - f'[killswitch]\nbestaetigungswort = "{wort}"\n', encoding="utf-8" - ) - (pfad / "scripts" / "killswitch.py").write_text(FALSCHER_KILLSWITCH, encoding="utf-8") - (pfad / "exit.txt").write_text(str(exit_code), encoding="utf-8") - return pfad - - -def wrapper(tmp: Path, *argumente: str) -> tuple[int, str]: - """Der echte abbau_glattstellen.sh aus einem leeren Arbeitsordner; stdout und stderr zusammen.""" - leer = tmp / "leer" - leer.mkdir(exist_ok=True) - lauf = subprocess.run( - ["bash", str(WRAPPER), *argumente], cwd=leer, capture_output=True, text=True, timeout=120 - ) - return lauf.returncode, lauf.stdout + lauf.stderr - - -class FalscherAlpaca: - """Ein Broker mit den drei Leseaufrufen; die Betraege sind erfunden und duerfen nie herauskommen.""" - - def __init__(self, jetzt, fehler=None): - self.jetzt = jetzt - self.fehler = fehler - - def marktuhr(self): - return SimpleNamespace(is_open=True, next_open=self.jetzt, next_close=self.jetzt + timedelta(minutes=20)) - - def positionen(self): - if self.fehler is not None: - raise RuntimeError(self.fehler) - return [ - SimpleNamespace(symbol="AAPL", menge=10, seite="long", marktwert=1234.56, einstandskurs=98.76), - SimpleNamespace(symbol="KO", menge=-20, seite="short", marktwert=-1111.11, einstandskurs=55.55), - ] - - def offene_orders(self): - return [SimpleNamespace(id="o-1", menge=10, limit=98.76)] - - -# --- Bedienung und Grundlinie --------------------------------------------------------------------- - -def test_ohne_modus_bricht_mit_exit_2_ab_und_ruft_nichts(): - welt = Welt() - codes = [] - for argumente in (["--jetzt", JETZT], ["--modus", "probe", "--jetzt", JETZT]): - with attrappe(welt), contextlib.redirect_stderr(io.StringIO()): - try: - ab.main(argumente) - except SystemExit as ende: - codes.append(ende.code) - else: - codes.append(None) - assert codes == [2, 2], codes - assert welt.aufrufe == [], welt.aufrufe - - -def test_grundlinie_je_instanz_genau_ein_eingriff_dann_flach(): - e = lauf_mit(Welt()) - assert e.code == 0, kurz(e) - assert e.welt.glatt == ["alpaca", "ibkr"], kurz(e) - assert e.welt.ordner == [ab.ORDNER_WSL["alpaca"], ab.ORDNER_WSL["ibkr"]], kurz(e) - assert e.welt.schlaf == [ab.WARTEN_S], kurz(e) - assert e.welt.gelesen == {"alpaca": 2, "ibkr": 2}, kurz(e) - assert titel_von(e) == ["Abbau Restbestand: flach"], kurz(e) - assert e.json["flach"] is True, kurz(e) - assert e.json["instanzen"]["alpaca"]["vorher"] == {"positionen": {"AAPL": 10}, "orders": 0}, kurz(e) - assert e.json["instanzen"]["ibkr"]["nachher"] == {"positionen": {}, "orders": 0}, kurz(e) - assert "Abbau Restbestand (automatisch, Auftrag Pascal 2026-10-07): flach." in e.notiz, e.notiz - assert "AAPL long 10" in e.notiz and "NEE long 20" in e.notiz, e.notiz - assert "#needs-review" not in e.notiz, e.notiz - - -# --- Fenster --------------------------------------------------------------------------------------- - -def test_vor_dem_fenster_eine_sekunde_tut_nichts(): - e = lauf_mit(Welt(), jetzt="2026-10-08T15:39:59") - assert e.code == 0 and e.welt.aufrufe == [] and e.dateien == [], kurz(e) - - -def test_fensterbeginn_genau_1540_greift(): - e = lauf_mit(Welt(), jetzt="2026-10-08T15:40:00") - assert e.welt.glatt == ["alpaca", "ibkr"], kurz(e) - - -def test_fensterende_genau_2145_am_freitag_greift(): - e = lauf_mit(Welt(), jetzt="2026-10-09T21:45:00") - assert e.welt.glatt == ["alpaca", "ibkr"], kurz(e) - - -def test_nach_dem_fenster_eine_sekunde_tut_nichts(): - e = lauf_mit(Welt(), jetzt="2026-10-09T21:45:01") - assert e.code == 0 and e.welt.aufrufe == [] and e.dateien == [], kurz(e) - - -# --- Marke flach und Entwarnung -------------------------------------------------------------------- - -def test_zweiter_lauf_nach_flach_tut_nichts(): - vorher = json.dumps({"zeit": "2026-10-08T15:45:00", "flach": True}) - e = lauf_mit(Welt(), ergebnis_vorher=vorher) - assert e.code == 0 and e.welt.aufrufe == [] and e.welt.meldungen == [], kurz(e) - assert e.ergebnis_text == vorher and e.notiz == "", kurz(e) - assert e.dateien == [ERGEBNIS, LOG], kurz(e) - - -def test_ergebnisdatei_ohne_echtes_true_laeuft_weiter(): - for vorher in ('{"flach": false}', '{"flach": "ja"}', '{"flach": 1}', "{kaputt", "[true]"): - e = lauf_mit(Welt(), ergebnis_vorher=vorher) - assert e.welt.glatt == ["alpaca", "ibkr"], (vorher, kurz(e)) - - -def test_erstes_flach_ohne_eingriff_meldet_entwarnung(): - vorher = json.dumps({"flach": False, "befunde": ["ibkr: Bestand nachher nicht lesbar; Ergebnis offen"]}) - e = lauf_mit(Welt(alpaca=FLACH, ibkr=FLACH), ergebnis_vorher=vorher) - assert e.code == 0 and e.welt.glatt == [] and e.welt.schlaf == [], kurz(e) - assert titel_von(e) == ["Abbau Restbestand: flach"], kurz(e) - assert ": flach." in e.notiz and "#needs-review" not in e.notiz, e.notiz - assert e.json["flach"] is True, kurz(e) - - -def test_gesperrte_instanz_verhindert_flach_auch_bei_flachem_bestand(): - e = lauf_mit(Welt(units=("inactive", "active"), alpaca=FLACH, ibkr=FLACH)) - assert e.code == 1 and e.welt.glatt == [], kurz(e) - assert e.json["flach"] is False, kurz(e) - assert titel_von(e) == ["Abbau Restbestand: Befund"], kurz(e) - assert "**Befund** #needs-review" in e.notiz, e.notiz - - -# --- Modus lesen ----------------------------------------------------------------------------------- - -def test_modus_lesen_ruft_den_wrapper_nie_und_schreibt_nichts(): - e = lauf_mit(Welt(), modus="lesen") - assert e.code == 0, kurz(e) - assert e.welt.glatt == [] and e.welt.meldungen == [] and e.welt.schlaf == [], kurz(e) - assert e.dateien == [], kurz(e) - for erwartet in ( - "Bestand alpaca: 1 Position(en): AAPL long 10; offene Orders 0", - "Glattstellen im Modus scharf: alpaca, ibkr", - "ERGEBNIS: gelesen, ohne Befund", - ): - assert erwartet in e.ausgabe, (erwartet, e.ausgabe) - - -def test_modus_lesen_nachts_bei_markt_zu_liest_beide_bestaende_ohne_eingriff(): - e = lauf_mit(Welt(uhr=ZU), modus="lesen", jetzt="2026-10-08T00:30:00") - assert e.code == 0, kurz(e) - assert e.welt.glatt == [] and e.welt.meldungen == [] and e.dateien == [], kurz(e) - assert e.welt.gelesen == {"alpaca": 1, "ibkr": 1}, kurz(e) - for erwartet in ( - ": ausserhalb", - "Runner alpaca (trading-paper.service): LoadState=loaded, ActiveState=inactive", - "Markt zu", - "Bestand ibkr: 7 Position(en): AAPL long 10, CAT long 10, JPM long 10, KO long 20, MSFT long 10, " - "NEE long 20, NVDA long 20; offene Orders 0", - "Glattstellen im Modus scharf: keine (Markt zu", - ): - assert erwartet in e.ausgabe, (erwartet, e.ausgabe) - - -def test_modus_lesen_liest_auch_nach_flach(): - e = lauf_mit(Welt(), modus="lesen", ergebnis_vorher='{"flach": true}') - assert e.welt.gelesen == {"alpaca": 1, "ibkr": 1} and e.welt.glatt == [], kurz(e) - assert "Ergebnisdatei sagt flach" in e.ausgabe, e.ausgabe - assert e.dateien == [ERGEBNIS], kurz(e) - - -# --- Marktuhr -------------------------------------------------------------------------------------- - -def test_markt_zu_scharf_still_ohne_eingriff(): - e = lauf_mit(Welt(uhr=ZU)) - assert e.code == 0 and e.welt.glatt == [] and e.welt.meldungen == [], kurz(e) - assert e.welt.gelesen == {"alpaca": 1, "ibkr": 0}, kurz(e) - assert e.dateien == [LOG] and e.notiz == "", kurz(e) - assert "Markt zu" in e.log, e.log - - -def test_markt_schliesst_in_599_s_still(): - e = lauf_mit(Welt(uhr={"offen": True, "sekunden_bis_schluss": 599})) - assert e.code == 0 and e.welt.glatt == [] and e.dateien == [LOG], kurz(e) - - -def test_markt_schliesst_in_genau_600_s_greift(): - e = lauf_mit(Welt(uhr={"offen": True, "sekunden_bis_schluss": 600})) - assert e.welt.glatt == ["alpaca", "ibkr"], kurz(e) - - -def test_marktuhr_unlesbar_befund_ohne_eingriff(): - e = lauf_mit(Welt(uhr=None)) - assert e.code == 1 and e.welt.glatt == [] and e.welt.schlaf == [], kurz(e) - assert titel_von(e) == ["Abbau Restbestand: Befund"], kurz(e) - assert e.json["flach"] is False, kurz(e) - assert "**Befund** #needs-review" in e.notiz and "Marktuhr nicht lesbar" in e.notiz, e.notiz - - -# --- Runner-Units ---------------------------------------------------------------------------------- - -def test_laufender_runner_sperrt_genau_seine_instanz(): - e = lauf_mit(Welt(units=("active", "inactive"))) - assert e.code == 1 and e.welt.glatt == ["ibkr"], kurz(e) - erwartet = "alpaca: Runner-Unit trading-paper.service laeuft (LoadState=loaded, ActiveState=active); nicht angefasst" - assert erwartet in e.notiz, e.notiz - assert "#needs-review" in e.notiz, e.notiz - e = lauf_mit(Welt(units=("inactive", "deactivating"))) - assert e.code == 1 and e.welt.glatt == ["alpaca"], kurz(e) - - -def test_runner_zustand_unlesbar_instanz_nicht_angefasst(): - for zustand in ("", "unknown", Roh(0, "")): - e = lauf_mit(Welt(units=(zustand, "inactive"))) - assert e.code == 1 and e.welt.glatt == ["ibkr"], (zustand, kurz(e)) - - -def test_runner_unit_not_found_sperrt_die_instanz(): - e = lauf_mit(Welt(units=(("not-found", "inactive"), "inactive"))) - assert e.code == 1 and e.welt.glatt == ["ibkr"], kurz(e) - assert "LoadState=not-found, ActiveState=inactive" in e.notiz and "#needs-review" in e.notiz, e.notiz - - -def test_runner_failed_gilt_als_stehend(): - e = lauf_mit(Welt(units=("failed", "failed"))) - assert e.code == 0 and e.welt.glatt == ["alpaca", "ibkr"], kurz(e) - - -# --- Bestaende ------------------------------------------------------------------------------------- - -def test_ibkr_ohne_ergebnis_zeile_gilt_als_unlesbar(): - roh = Roh(1, "Positionen: 1\n AAPL.XNAS 10\nOffene Orders: 0\n") - e = lauf_mit(Welt(ibkr=roh)) - assert e.code == 1 and e.welt.glatt == ["alpaca"], kurz(e) - assert "ibkr: Bestand nicht lesbar" in e.notiz, e.notiz - - -def test_ibkr_exit_4_gilt_als_unlesbar(): - e = lauf_mit(Welt(ibkr=Roh(4, ibkr_text(IBKR7)))) - assert e.code == 1 and e.welt.glatt == ["alpaca"], kurz(e) - - -def test_ibkr_zahl_und_zeilen_passen_nicht(): - roh = Roh(0, "Positionen: 2\n AAPL.XNAS 10\nOffene Orders: 0\nERGEBNIS: gelesen\n") - e = lauf_mit(Welt(ibkr=roh)) - assert e.code == 1 and e.welt.glatt == ["alpaca"], kurz(e) - - -def test_ibkr_exit_1_mit_ergebnis_gelesen_gilt_als_gelesen(): - roh = Roh(1, ibkr_text(IBKR7, schluss="ERGEBNIS: gelesen, mit Befund")) - e = lauf_mit(Welt(ibkr=roh)) - assert e.code == 0 and e.welt.glatt == ["alpaca", "ibkr"], kurz(e) - - -def test_alpaca_bestand_unlesbar_nicht_angefasst(): - e = lauf_mit(Welt(alpaca=None)) - assert e.code == 1 and e.welt.glatt == ["ibkr"], kurz(e) - assert "alpaca: Bestand nicht lesbar (BrokerFehler: Test); nicht angefasst" in e.notiz, e.notiz - - -def test_fremder_fehlertext_mit_zeilenumbruch_bleibt_einzeilig(): - e = lauf_mit(Welt(alpaca=None, alpaca_fehler="BrokerFehler: HTTP 500\n## Zweite Zeile")) - assert e.code == 1 and e.welt.glatt == ["ibkr"], kurz(e) - assert "BrokerFehler: HTTP 500 ## Zweite Zeile" in e.notiz, e.notiz - assert not any(zeile.startswith("## Zweite") for zeile in e.notiz.splitlines()), e.notiz - assert not any(zeile.startswith("## Zweite") for zeile in e.log.splitlines()), e.log - for titel, text in e.welt.meldungen: - assert "\n" not in text and "\r" not in text, text - - -def test_nur_offene_order_ohne_position_wird_glattgestellt(): - e = lauf_mit(Welt(alpaca={"positionen": [], "orders": 1}, ibkr=FLACH)) - assert e.code == 0 and e.welt.glatt == ["alpaca"], kurz(e) - - -def test_flache_instanz_wird_nicht_angefasst(): - e = lauf_mit(Welt(alpaca=FLACH)) - assert e.code == 0 and e.welt.glatt == ["ibkr"], kurz(e) - assert e.json["instanzen"]["alpaca"]["glattstellen"] is None, kurz(e) - - -# --- Ergebnis nach dem Eingriff -------------------------------------------------------------------- - -def test_rest_offen_nach_eingriff(): - e = lauf_mit(Welt(ibkr_nachher={"positionen": [("NEE.XNYS", 20)], "orders": 0})) - assert e.code == 0 and e.json["flach"] is False, kurz(e) - assert titel_von(e) == ["Abbau Restbestand: Rest offen"], kurz(e) - assert ": Rest offen." in e.notiz and "#needs-review" not in e.notiz, e.notiz - - -def test_offene_order_nachher_ist_nicht_flach(): - e = lauf_mit(Welt(alpaca_nachher={"positionen": [], "orders": 1})) - assert e.json["flach"] is False, kurz(e) - assert titel_von(e) == ["Abbau Restbestand: Rest offen"], kurz(e) - - -def test_wrapper_exit_1_und_danach_flach_heisst_flach_mit_befund(): - e = lauf_mit(Welt(wrapper_exit=1)) - assert e.code == 1, kurz(e) - assert e.welt.glatt == ["alpaca", "ibkr"], kurz(e) - assert e.json["flach"] is True, kurz(e) - assert titel_von(e) == ["Abbau Restbestand: flach, mit Befund"], kurz(e) - assert ": flach, mit Befund #needs-review." in e.notiz, e.notiz - assert "alpaca: glattstellen endete mit Exit 1" in e.notiz, e.notiz - - -def test_glattstellen_wirft_exit_none_befund_nachlesen_und_meldung(): - e = lauf_mit(Welt(wrapper_wirft=True)) - assert e.code == 1 and e.welt.glatt == ["alpaca", "ibkr"], kurz(e) - assert e.welt.schlaf == [ab.WARTEN_S] and e.welt.gelesen == {"alpaca": 2, "ibkr": 2}, kurz(e) - assert titel_von(e) == ["Abbau Restbestand: Befund"], kurz(e) - assert "alpaca: glattstellen endete mit Exit None" in e.notiz, e.notiz - assert e.json["instanzen"]["alpaca"]["glattstellen"]["exit"] is None, kurz(e) - - -def test_nachher_unlesbar_ist_befund_und_nicht_flach(): - e = lauf_mit(Welt(ibkr_nachher=None)) - assert e.code == 1 and e.json["flach"] is False, kurz(e) - assert "ibkr: Bestand nachher nicht lesbar" in e.notiz, e.notiz - - -def test_ausnahme_traegt_nur_den_typnamen_in_notiz_und_meldung(): - welt = Welt() - - def wirft(sekunden): - raise RuntimeError(f"Order an {KONTO} limit {BETRAG}") - - welt.schlafe = wirft - e = lauf_mit(welt) - assert e.code == 1, kurz(e) - assert "Befund: abgebrochen: RuntimeError" in e.notiz, e.notiz - texte = [e.notiz, e.ergebnis_text or ""] + [titel + " " + text for titel, text in e.welt.meldungen] - for text in texte: - assert KONTO not in text and BETRAG not in text, text - assert "Traceback" in e.log, e.log - - -# --- wsl.exe-Fehler und NUL ------------------------------------------------------------------------ - -def test_aufruf_nach_wsl_entfernt_nul_und_setzt_wsl_utf8(): - gesehen = [] - - def falscher_start(befehl, **optionen): - gesehen.append((befehl, optionen)) - return SimpleNamespace( - returncode=4294967295, - stdout="Es ist keine Distribution".encode("utf-16-le"), - stderr="Fehler".encode("utf-16-le"), - ) - - alt = ab.starte - ab.starte = falscher_start - try: - code, ausgabe, fehler = ab.wsl(["systemctl", "show", "trading-paper.service"], 5) - finally: - ab.starte = alt - assert code == 4294967295, code - assert NUL not in ausgabe and NUL not in fehler, (ausgabe, fehler) - assert ausgabe == "Es ist keine Distribution" and fehler == "Fehler", (ausgabe, fehler) - assert len(gesehen) == 1 and gesehen[0][0][0] == ab.WSL, gesehen - assert gesehen[0][1]["env"]["WSL_UTF8"] == "1", gesehen[0][1].get("env", {}).get("WSL_UTF8") - - -def test_runner_zustand_mit_nul_kommt_sauber_in_notiz_log_und_meldung(): - roh = Roh(0, "LoadState=loaded\nActiveState=E" + NUL + "s" + NUL + "\n") - e = lauf_mit(Welt(units=(roh, "inactive"))) - assert e.code == 1 and e.welt.glatt == ["ibkr"], kurz(e) - assert titel_von(e) == ["Abbau Restbestand: Befund"], kurz(e) - assert NUL not in e.notiz and NUL not in e.log, (e.notiz, e.log) - welt = Welt() - with attrappe(welt): - antwort = ab.melde("Abbau Restbestand: Probe" + NUL, "Text" + NUL, ab.SCHARF) - assert antwort == "gesendet", antwort - assert welt.meldungen == [("Abbau Restbestand: Probe", "Text")], welt.meldungen - - -# --- Abdichtung ------------------------------------------------------------------------------------ - -def test_glattstellen_und_melden_ohne_scharf_rufen_nie_wsl(): - welt = Welt() - with attrappe(welt): - try: - ab.glattstelle("alpaca", ab.LESEN) - except RuntimeError: - abgelehnt = True - else: - abgelehnt = False - antwort = ab.melde("Abbau Restbestand: Probe", "Text", ab.LESEN) - assert abgelehnt, "glattstelle lief im Modus lesen" - assert welt.aufrufe == [], welt.aufrufe - assert antwort.startswith("nicht gesendet"), antwort - - -def test_abdichtung_alle_pfade_nach_draussen_zeigen_ins_leere(): - assert not Path(ab.WSL).exists(), ab.WSL - assert ab.starte is subprocess.run, ab.starte - for pfad in (ab.WSL_SKRIPT, ab.ALPACA_LESEN, ab.IBKR_LESEN, ab.GLATTSTELLEN, *ab.ORDNER_WSL.values()): - assert pfad.startswith("/nicht-vorhanden/"), pfad - try: - ab.wsl(["systemctl", "show", "trading-paper.service"], 5) - except FileNotFoundError: - return - raise AssertionError("wsl() startete einen Prozess, obwohl WSL ins Leere zeigt") - - -# --- WSL: Wrapper, Kette, Helfer ------------------------------------------------------------------- - -def test_wsl_wrapper_waehlt_alpaca_ersetzt_das_wort_und_maskiert_konto_und_betraege(): - nur_in_wsl() - with tempfile.TemporaryDirectory() as tmp: - tmp = Path(tmp) - ordner = instanzordner(tmp / "alpaca", exit_code=1) - code, text = wrapper(tmp, "alpaca", str(ordner)) - angekommen = (ordner / "aufgerufen").read_text(encoding="utf-8") - assert code == 1, (code, text) - assert angekommen == f"--broker alpaca glattstellen --grund {GRUND} --bestaetigung {TESTWORT}", angekommen - assert TESTWORT not in text and "[wort]" in text, text - assert BETRAG not in text and "Kontowert [x]" in text, text - assert KONTO not in text and "Order an [konto]" in text, text - assert BETRAG_OHNE_GELDWORT not in text and "limit [x]" in text, text - assert "venvs/trading/bin/python" in text, text - - -def test_wsl_wrapper_ibkr_mit_attrappe_nimmt_ihr_venv_und_broker_vor_dem_unterbefehl(): - nur_in_wsl() - with tempfile.TemporaryDirectory() as tmp: - tmp = Path(tmp) - ordner = instanzordner(tmp / "ibkr") - code, text = wrapper(tmp, "ibkr", str(ordner), "--broker", "attrappe") - angekommen = (ordner / "aufgerufen").read_text(encoding="utf-8") - assert code == 0, (code, text) - assert angekommen.startswith("--broker attrappe glattstellen --grund "), angekommen - assert "venvs/trading-ibkr/bin/python" in text, text - - -def test_wsl_wrapper_lehnt_anderen_broker_und_fremde_angaben_ab(): - nur_in_wsl() - with tempfile.TemporaryDirectory() as tmp: - tmp = Path(tmp) - ordner = instanzordner(tmp / "alpaca") - faelle = ( - ("alpaca", str(ordner), "--broker", "alpaca"), - ("alpaca", str(ordner), "--broker"), - ("alpaca", str(ordner), "--weiter"), - ("live", str(ordner)), - ("alpaca",), - ) - codes = [wrapper(tmp, *fall)[0] for fall in faelle] - aufgerufen = (ordner / "aufgerufen").exists() - assert codes == [2, 2, 2, 2, 2], codes - assert not aufgerufen, "killswitch.py wurde trotz Bedienfehler gerufen" - - -def test_wsl_wrapper_ohne_wort_ruft_killswitch_nicht(): - nur_in_wsl() - with tempfile.TemporaryDirectory() as tmp: - tmp = Path(tmp) - ordner = instanzordner(tmp / "alpaca", wort=None) - code, text = wrapper(tmp, "alpaca", str(ordner)) - aufgerufen = (ordner / "aufgerufen").exists() - assert code == 4 and not aufgerufen, (code, aufgerufen, text) - - -def test_wsl_kette_wort_konto_und_betrag_stehen_nirgends(): - nur_in_wsl() - with tempfile.TemporaryDirectory() as tmp: - tmp = Path(tmp) - ordner = {instanz: instanzordner(tmp / instanz, exit_code=1) for instanz in ab.INSTANZEN} - - def echter_wrapper(argumente): - assert Path(argumente[1]).resolve().is_relative_to(tmp.resolve()), argumente - code, text = wrapper(tmp, *argumente) - return code, text, "" - - alt = ab.ORDNER_WSL - ab.ORDNER_WSL = {instanz: str(pfad) for instanz, pfad in ordner.items()} - try: - e = lauf_mit(Welt(echter_wrapper=echter_wrapper)) - finally: - ab.ORDNER_WSL = alt - angekommen = (ordner["ibkr"] / "aufgerufen").read_text(encoding="utf-8") - assert e.code == 1 and e.welt.glatt == ["alpaca", "ibkr"], kurz(e) - assert angekommen.startswith("--broker ibkr glattstellen --grund "), angekommen - assert angekommen.endswith("--bestaetigung " + TESTWORT), angekommen - assert "[wort]" in e.ergebnis_text and "Kontowert [x]" in e.notiz, (e.ergebnis_text, e.notiz) - assert "[konto]" in e.notiz, e.notiz - texte = [e.ausgabe, e.ergebnis_text, e.log, e.notiz] + [titel + " " + text for titel, text in e.welt.meldungen] - for text in texte: - for geheim in (TESTWORT, KONTO, BETRAG, BETRAG_OHNE_GELDWORT): - assert geheim not in text, (geheim, text) - - -def test_wsl_alpaca_helfer_gibt_stueck_ohne_betrag(): - nur_in_wsl() - import abbau_alpaca_lesen as helfer - - jetzt = datetime(2026, 10, 8, 14, 0, tzinfo=timezone.utc) - daten = helfer.lies(FalscherAlpaca(jetzt), jetzt) - text = json.dumps(daten) - assert daten["uhr"] == {"offen": True, "sekunden_bis_schluss": 1200}, daten - assert daten["positionen"] == [{"symbol": "AAPL", "menge": 10}, {"symbol": "KO", "menge": -20}], daten - assert daten["orders"] == 1 and daten["bestand_fehler"] is None, daten - for betrag in ("1234.56", "98.76", "1111.11", "55.55"): - assert betrag not in text, (betrag, text) - - -def test_wsl_alpaca_helfer_maskiert_konto_und_betraege_im_fehlertext(): - nur_in_wsl() - import abbau_alpaca_lesen as helfer - - jetzt = datetime(2026, 10, 8, 14, 0, tzinfo=timezone.utc) - fehler = f"Order an {KONTO}: limit {BETRAG_OHNE_GELDWORT}, Kontowert {BETRAG} USD" - daten = helfer.lies(FalscherAlpaca(jetzt, fehler=fehler), jetzt) - text = json.dumps(daten) - assert daten["positionen"] is None and daten["orders"] is None, daten - assert daten["bestand_fehler"].startswith("RuntimeError: "), daten - assert "Order an [konto]" in daten["bestand_fehler"] and "limit [x]" in daten["bestand_fehler"], daten - for geheim in (KONTO, BETRAG, BETRAG_OHNE_GELDWORT): - assert geheim not in text, (geheim, text) - assert daten["uhr"] == {"offen": True, "sekunden_bis_schluss": 1200}, daten - - -def test_wsl_ibkr_lesen_ohne_erreichbaren_ordner_startet_nichts(): - nur_in_wsl() - with tempfile.TemporaryDirectory() as tmp: - leer = Path(tmp) - ohne = subprocess.run(["bash", str(IBKR_SKRIPT)], cwd=leer, capture_output=True, text=True, timeout=60) - fehlt = subprocess.run( - ["bash", str(IBKR_SKRIPT), "/nicht-vorhanden/ibkr"], cwd=leer, capture_output=True, text=True, timeout=60 - ) - assert ohne.returncode == 2, (ohne.returncode, ohne.stderr) - assert fehlt.returncode == 4, (fehlt.returncode, fehlt.stderr) - - -# --- Lauf als Skript ------------------------------------------------------------------------------- - -def main() -> int: - faelle = [(name, wert) for name, wert in globals().items() if name.startswith("test_") and callable(wert)] - abweichungen = 0 - uebersprungen = 0 - for name, fall in faelle: - try: - fall() - except Uebersprungen as grund: - uebersprungen += 1 - print(f"UEBERSPRUNGEN {name}: {grund}") - continue - except (Exception, SystemExit) as ausnahme: - abweichungen += 1 - print(f"FEHL {name}: {type(ausnahme).__name__}: {str(ausnahme)[:400]!r}") - continue - print(f"OK {name}") - print(f"ERGEBNIS: {len(faelle)} Faelle, {abweichungen} Abweichung(en), {uebersprungen} uebersprungen") - return 1 if abweichungen else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/abbau_restbestand_einrichten.ps1 b/.vault-tools/trading-plattform-pruefung/abbau_restbestand_einrichten.ps1 deleted file mode 100644 index 3ca70d8..0000000 --- a/.vault-tools/trading-plattform-pruefung/abbau_restbestand_einrichten.ps1 +++ /dev/null @@ -1,54 +0,0 @@ -# Richtet die einmalige Windows-Aufgabe "Trading Plattform - Abbau Restbestand" ein oder entfernt sie. -# Ersteller: Pascal Michel Erstellt: 2026-10-07 -# Doku: LIESMICH.md in diesem Ordner, Abschnitt "Paket AB" -# -# Pascal 2026-10-07: am 2026-10-08 soweit es geht alles verkaufen. Neun Ausloeser: acht Termine -# (Do 2026-10-08 15:45, 16:15, 17:00, 19:00, 21:00; Ersatz Fr 2026-10-09 15:45, 16:30, 19:00) und -# fuenf Minuten nach jeder Anmeldung. Der Anmelde-Ausloeser holt einen verpassten Termin nach, wie bei -# der Abendkontrolle; dass StartWhenAvailable das fuer eine Aufgabe mit Interactive allein tut, ist -# nicht belegt. abbau_restbestand.py tut ausserhalb seines Fensters und nach "flach" nichts; ein Lauf -# zur falschen Zeit ist also harmlos. Nach dem 2026-10-09 mit -Aktion entfernen abbauen, sonst bleibt -# der Anmelde-Ausloeser stehen. -# -# Aufruf: powershell -NoProfile -File abbau_restbestand_einrichten.ps1 [-Aktion einrichten|entfernen] -param( - [ValidateSet("einrichten", "entfernen")] - [string]$Aktion = "einrichten" -) - -$Name = "Trading Plattform - Abbau Restbestand" -if ($Aktion -eq "entfernen") { - Unregister-ScheduledTask -TaskName $Name -Confirm:$false - return -} - -$Py = "C:\Users\Pascal Michel\AppData\Local\Programs\Python\Python313\pythonw.exe" -$Skript = "D:\Claude\.vault-tools\trading-plattform-pruefung\abbau_restbestand.py" -if (-not (Test-Path $Py)) { throw "Interpreter fehlt: $Py" } -if (-not (Test-Path $Skript)) { throw "Skript fehlt: $Skript" } - -$Termine = @( - "2026-10-08T15:45:00", "2026-10-08T16:15:00", "2026-10-08T17:00:00", "2026-10-08T19:00:00", - "2026-10-08T21:00:00", "2026-10-09T15:45:00", "2026-10-09T16:30:00", "2026-10-09T19:00:00" -) -$Benutzer = "$env:USERDOMAIN\$env:USERNAME" -$Aufruf = New-ScheduledTaskAction -Execute $Py -Argument ('"' + $Skript + '" --modus scharf') -WorkingDirectory "D:\Claude" -$Anmeldung = New-ScheduledTaskTrigger -AtLogOn -User $Benutzer -$Anmeldung.Delay = "PT5M" -$Ausloeser = @($Termine | ForEach-Object { New-ScheduledTaskTrigger -Once -At ([datetime]$_) }) + @($Anmeldung) -$Wer = New-ScheduledTaskPrincipal -UserId $Benutzer -LogonType Interactive -RunLevel Limited -$Regeln = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` - -ExecutionTimeLimit (New-TimeSpan -Minutes 45) -MultipleInstances IgnoreNew -Register-ScheduledTask -TaskName $Name -Action $Aufruf -Trigger $Ausloeser -Principal $Wer ` - -Settings $Regeln -Force ` - -Description "Abbau des Paper-Restbestands nach dem Netzschalter-Abriss vom 2026-10-07 (Auftrag Pascal). Siehe abbau_restbestand.py." | Out-Null - -$Aufgabe = Get-ScheduledTask -TaskName $Name -"{0}: {1}" -f $Aufgabe.TaskName, $Aufgabe.State -"Ausloeser: {0}" -f @($Aufgabe.Triggers).Count -$Aufgabe.Triggers | ForEach-Object { "Termin: {0} {1} {2}" -f $_.CimClass.CimClassName, $_.StartBoundary, $_.Delay } -"LogonType: {0}" -f $Aufgabe.Principal.LogonType -"StartWhenAvailable: {0}" -f $Aufgabe.Settings.StartWhenAvailable -"MultipleInstances: {0}" -f $Aufgabe.Settings.MultipleInstances -"ExecutionTimeLimit: {0}" -f $Aufgabe.Settings.ExecutionTimeLimit -"Naechster Lauf: {0}" -f (Get-ScheduledTaskInfo -TaskName $Name).NextRunTime diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle.py deleted file mode 100644 index 7d9a655..0000000 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle.py +++ /dev/null @@ -1,783 +0,0 @@ -"""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/.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, solange es - sich schreiben laesst; sonst bleiben nur seine Zeilen "gescheitert" in der Notiz seines Tags, soweit sie sich - schreiben liessen, und die Zeile zu lauf.log in der Notiz von heute (kritische Pruefung NR, Task 746). -Seit Paket IB2 zwei Teile je Tag, nacheinander: alpaca wie oben, dann ibkr (abendkontrolle_wsl.py ---teil ibkr) mit eigener Ergebnisdatei -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. -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, -.tar ueber denselben Schluessel holen, Summe -pruefen, nach SICHERUNG_ZIEL//.tar umbenennen, beim Host bestaetigen, zuletzt -.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). -Seit Paket NT (Quorum 4): Endet die Frage nach einer Summe oder die Bestaetigung mit ssh-Exit 255 oder -im Zeitlimit, ist der Docker-Host nicht erreichbar, und die Sicherung hoert sofort auf: eine Zeile in der -Notiz von heute, Exit 1. Bricht nur das Archiv so ab, scheitert allein diese Instanz. --tag ausser der -Form JJJJ-MM-TT oder ohne gueltiges Datum endet gleich nach dem Zerlegen mit Exit 2, ohne Notiz und ssh. -Seit Paket NR (kritische Pruefung NT, Befund 3): Laesst sich lauf.log nicht schreiben (Sperre, Rechte, volle -Platte), haelt das weder Ergebnisdatei noch Notizzeile noch Sicherung auf. Der Lauf schreibt am Ende eine -Zeile "Abendkontrolle lauf.log (automatisch): **nicht schreibbar** ()" in die Notiz von heute und endet -mit Exit 1. Seit der Plankritik NR: Laesst sich die Ergebnisdatei eines Tages nicht schreiben, bekommt seine -Zeile in der Notiz eine Unterzeile mit dem Typ der Ausnahme, die folgenden Tage des Teils laufen weiter, und -der Lauf endet mit Exit 1. -Aufruf von Hand: python abendkontrolle.py [--tag JJJJ-MM-TT] [--vault D:\\Claude] [--ohne-wsl ] - [--quelle wsl|dockerhost] [--quelle-ibkr wsl|dockerhost] [--sicherung-ziel ] -""" - -import argparse -import hashlib -import json -import os -import re -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 -from time import monotonic - -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 = "kontrolle@192.168.250.10" -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)", -} - - -# 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//.tar samt .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 = date(2026, 10, 13) -# 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. -# 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 tag_gueltig(tag: str) -> bool: - """Ist tag ein Datum JJJJ-MM-TT, das es gibt, nur mit ASCII-Ziffern? Seit Paket NT fuer --tag in main. - - TAGFORM allein liesse 2026-13-05 durch, date.fromisoformat allein seit Python 3.11 auch 20261005. - """ - if not TAGFORM.fullmatch(tag): - return False - try: - date.fromisoformat(tag) - except ValueError: - return False - return True - - -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 und abgelegt hat. - - Der Host legt je Tag .json (alpaca) und -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 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() - - -class HostNichtErreichbar(Exception): - """ssh selbst scheiterte (Exit 255) oder ein Befehl der Sicherung lief in sein Zeitlimit (seit Paket NT). - - Beim Archiv macht hole_sicherung daraus den Fehlschlag dieser einen Instanz. - """ - - -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. Seit Paket NT - (Quorum 4, G1): abendkontrolle-lesen.sh endet mit 0 bis 4. Exit 255 meldet ssh selbst: keine - Verbindung oder Anmeldung, laut Quelltext von OpenSSH-portable 9.5p1 auch ein Befehl, den auf dem - Host ein Signal beendet (fuer Win32-OpenSSH 9.5p2 auf diesem Rechner nicht nachgelesen). Er und ein - abgelaufenes Zeitlimit heissen: Der Docker-Host ist nicht erreichbar (HostNichtErreichbar). - """ - try: - if stdout is None: - lauf = subprocess.run( - ssh_aufruf(befehl), capture_output=True, timeout=timeout, creationflags=KEIN_FENSTER, - ) - else: - lauf = subprocess.run( - ssh_aufruf(befehl), stdout=stdout, stderr=subprocess.PIPE, timeout=timeout, - creationflags=KEIN_FENSTER, - ) - except subprocess.TimeoutExpired: - raise HostNichtErreichbar(f"{befehl}: Zeitlimit {timeout} s abgelaufen") from None - if lauf.returncode == 255: - raise HostNichtErreichbar(f"{befehl}: {fehlerzeile(lauf)}") - return lauf - - -def summe_auf_d(ordner: Path, instanz: str) -> str | None: - """Die Summe aus .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. Seine Form (JJJJ-MM-TT, ein Datum, - # das es gibt) prueft seit Paket NT main gleich nach dem Zerlegen der Argumente (tag_gueltig). - 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 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 - mit der Summe des Hosts, prueft, legt ab und bestaetigt. - - Reihenfolge: Archiv in die Nachbardatei .tar.teil holen, Summe pruefen, eine alte - .tar.sha256 entfernen, auf .tar umbenennen (ersetzt eine alte Fassung), beim - Host bestaetigen, zuletzt .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: - try: - lauf = ssh_lauf(f"sicherung {name}", SICHERUNG_TIMEOUT_S, strom) - except HostNichtErreichbar as abbruch: - # Seit Paket NT (Plankritik, Befund 1): Mitten im Archiv heissen Zeitlimit und Exit 255 nicht, - # dass der Host fehlt; er hat die Summe gerade beantwortet. Nur diese Instanz scheitert, und - # ein einzelnes zu langsames Archiv haelt nicht jeden Lauf an derselben Stelle an. Ist der - # Host wirklich weg, merkt es die naechste Frage nach einer Summe. - raise RuntimeError(f"Archiv nicht geholt ({abbruch})") from None - 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 HostNichtErreichbar as ausnahme: - # Seit Paket NT (Quorum 4, G1): Ohne Host scheiterte sonst jeder Tag des Fensters mit eigener - # Zeile, auch in neu angelegten Notizen von Wochenenden. Die Sicherung hoert hier auf: eine - # Zeile in der Notiz von heute, Exit 1. Fertige Tage behalten ihre Zeilen; was der - # angefangene Tag schon hat, nennt diese Zeile. Auf D: entsteht danach nichts mehr. - logge(ablage, f"sicherung {kandidat}-{instanz}: Docker-Host nicht erreichbar ({ausnahme}), Sicherung abgebrochen") - zeile = ( - f"- {jetzt:%H:%M} Uhr — {SICHERUNG_KOPF}: **gescheitert** #needs-review. Docker-Host nicht " - f"erreichbar ({str(ausnahme)[:200]}), Sicherung abgebrochen." - ) - if stuecke: - zeile += f" Bis dahin am {kandidat}: {'; '.join(stuecke)}." - zeile += " Der nächste Lauf versucht es erneut." - try: - schreibe_notiz(vault, jetzt.date().isoformat(), zeile) - except Exception as zweite: - logge(ablage, f"sicherung: Notiz nicht geschrieben {zweite!r}") - return 1 - 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 [] - 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) - - -# Seit Paket NR (kritische Pruefung NT, Befund 3): der Typ der letzten Ausnahme, an der lauf.log in diesem -# Lauf scheiterte; None, solange es ging. main setzt ihn zu Beginn zurueck, nur logge setzt ihn. -LOG_FEHLER: str | None = None -LOG_KOPF = "Abendkontrolle lauf.log (automatisch)" - - -def logge(ablage: Path, text: str) -> None: - """Eine Zeile in lauf.log. Wirft seit Paket NR nicht mehr. - - Vorher warf ein gesperrtes oder nicht schreibbares lauf.log in main vor fahre_teil, in fahre_teil vor - der Zeile in der Notiz und in der Sicherung vor der Zeile zum unerreichbaren Host; uebrig blieb nur - Exit 1. Jetzt merkt sich logge den Typ der Ausnahme in LOG_FEHLER, und main schreibt dazu am Ende - eine Zeile in die Notiz von heute. - """ - global LOG_FEHLER - try: - 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") - except Exception as ausnahme: - LOG_FEHLER = type(ausnahme).__name__ - - -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", - ) - zerleger.add_argument( - "--sicherung-ziel", - help="Ordner fuer die Sicherungen statt SICHERUNG_ZIEL (Angriffsfaelle)", - ) - argumente = zerleger.parse_args(argv) - # Seit Paket NT (Quorum 4): --tag gleich hier pruefen. Der Tag wird Name der Notiz, Pfad unter - # SICHERUNG_ZIEL und Teil eines Befehls auf dem Host; vorher legte ein Tag ausser der Form - # Inbox/.md an, mit ../ auch ausserhalb von Inbox/. Leer zaehlt als ausser der Form. Die - # Ausgabe ist ASCII (!a), damit kein Zeichen an der Kodierung von stderr scheitert. - if argumente.tag is not None and not tag_gueltig(argumente.tag): - print(f"abendkontrolle: --tag {argumente.tag!a} ist kein Datum JJJJ-MM-TT", file=sys.stderr) - return 2 - # Seit Paket NR: Jeder Lauf beginnt ohne Fehler an lauf.log. Die Angriffsfaelle rufen main mehrmals im - # selben Prozess; ohne das Zuruecksetzen truege ein Lauf den Fehler des vorigen weiter. - global LOG_FEHLER - LOG_FEHLER = None - # 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() - # 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, laeuft der naechste trotzdem - # (Pruefung IB2). Seit Paket NR werfen weder ein gesperrtes lauf.log (logge) noch eine nicht - # schreibbare Ergebnisdatei (fahre_teil). - 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 - # 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 - # Seit Paket NR (kritische Pruefung NT, Befund 3): Scheiterte lauf.log in diesem Lauf, einmal eine Zeile - # in der Notiz von heute mit dem Typ der Ausnahme, ohne Pfad und ohne Inhalt, und Exit 1. Scheitert - # auch die Notiz, bleibt es bei Exit 1. - if LOG_FEHLER is not None: - code = 1 - log_zeile = ( - f"- {jetzt:%H:%M} Uhr — {LOG_KOPF}: **nicht schreibbar** ({LOG_FEHLER}) #needs-review. Mindestens " - "ein Eintrag dieses Laufs fehlt dort; Ergebnisdateien, Notizzeilen und Sicherung hängen nicht davon ab." - ) - try: - schreibe_notiz(vault, jetzt.date().isoformat(), log_zeile) - 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. - # Seit Paket NR (Plankritik NR): Liess sich die Ergebnisdatei nicht schreiben, flog die Ausnahme vorher - # aus fahre_teil, und es fehlten die Zeile dieses Tages und die aller folgenden Tage des Teils. Jetzt - # bekommt die Zeile eine Unterzeile mit dem Typ der Ausnahme, die folgenden Tage laufen weiter, Exit 1. - # Ob ein spaeterer Lauf ohne --tag den Tag erneut prueft, entscheidet faellige_tage: nur einen Werktag ab - # ERSTER_TAG bzw. IBKR_ERSTER_TAG, hoechstens NACHHOLEN_TAGE zurueck, und nur, wenn unter dem Namen der - # Datei nichts liegt, auch keine halb geschriebene Datei. Einen Tag, den nur --tag erreicht (Wochenende, - # vor dem ersten Tag, aelter als das Fenster), prueft kein Lauf von selbst erneut (kritische Pruefung NR). - zusatz = "" - if ergebnis.get("exit") != 2: - try: - (ablage / f"{tag}{endung}.json").write_text( - json.dumps(ergebnis, ensure_ascii=False, indent=1), encoding="utf-8", newline="\n" - ) - except Exception as ausnahme: - code = 1 - logge(ablage, f"{vorsatz}{tag}: Ergebnisdatei nicht geschrieben {ausnahme!r}") - zusatz = ( - f"\n - Ergebnisdatei nicht geschrieben ({type(ausnahme).__name__}) #needs-review. Ein späterer " - "Lauf ohne --tag prüft den Tag nur erneut, wenn er ein Werktag ab dem ersten Prüftag des Teils " - f"ist, höchstens {NACHHOLEN_TAGE} Kalendertage zurückliegt und unter dem Namen der Datei nichts " - "liegt, auch keine halb geschriebene. Sonst prüft ihn kein Lauf von selbst." - ) - 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) + zusatz) - except Exception as ausnahme: - code = 1 - logge(ablage, f"{vorsatz}{tag}: Notiz nicht geschrieben {ausnahme!r}") - return code - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_angriffe.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle_angriffe.py deleted file mode 100644 index 0155fbd..0000000 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle_angriffe.py +++ /dev/null @@ -1,1080 +0,0 @@ -"""Angriffsfaelle fuer die Abendkontrolle (abendkontrolle_wsl.py in WSL, abendkontrolle.py unter Windows). - -Koordinator-Werkzeug, nicht fuers Repo. Jeder Fall laeuft einzeln auf einer Wegwerfkopie, zuerst die -Grundlinie; ohne Netz (--ohne-broker/--ohne-kerzen, Kalender und Journal aus Dateien, eine Ersatz- -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. -""" - -import atexit -import json -import os -import shutil -import sqlite3 -import subprocess -import sys -import tempfile -from pathlib import Path - -HIER = Path(__file__).resolve().parent -TAG = "2026-09-23" -ECHTES_PROJEKT = Path("/mnt/d/Claude/Code Projekte/active - Trading Plattform") -ECHTES_PROTOKOLL = ECHTES_PROJEKT / "artifacts" / "paper" / "2026-09-23T131023-paper.jsonl" -KALENDER_TAG = [{"date": TAG, "open": "09:30", "close": "16:00"}] -JOURNAL_GUT = ( - "Ende: boersenschluss, Position 0, Orders 2, Killswitch aktiv, Exit 0\n" - "trading-paper.service: Deactivated successfully.\n" -) -DIENSTE_GUT = { - "trading-dashboard.service": "active", - "trading-paper.timer": "active", - "trading-auswertung.timer": "active", -} -abweichungen: list[str] = [] - - -# --- WSL-Teil ---------------------------------------------------------------------------------- - -def baue_projekt(wurzel: Path, killswitch: str | None = "aktiv") -> Path: - projekt = wurzel / "projekt" - (projekt / "artifacts" / "paper").mkdir(parents=True) - (projekt / "data" / "paper" / "auswertungen").mkdir(parents=True) - (projekt / "config").mkdir() - # Die Auswertung importiert trading.geheimnisse aus dem Arbeitsverzeichnis. - (projekt / "trading").symlink_to(ECHTES_PROJEKT / "trading") - shutil.copyfile(ECHTES_PROTOKOLL, projekt / "artifacts" / "paper" / f"{TAG}T131023-paper.jsonl") - (projekt / "data" / "paper" / "auswertungen" / f"{TAG}.md").write_text("# Bericht\n", encoding="utf-8") - with sqlite3.connect(projekt / "data" / "registry.db") as db: - db.execute("CREATE TABLE killswitch_zustand (id INTEGER PRIMARY KEY, zustand TEXT, seit TEXT, ausloeser TEXT)") - if killswitch is not None: - db.execute("INSERT INTO killswitch_zustand VALUES (1, ?, '2026-09-23T13:31:00+00:00', 'pascal')", (killswitch,)) - (wurzel / "kalender.json").write_text(json.dumps(KALENDER_TAG), encoding="utf-8") - (wurzel / "journal.txt").write_text(JOURNAL_GUT, encoding="utf-8") - (wurzel / "dienste.json").write_text(json.dumps(DIENSTE_GUT), encoding="utf-8") - return projekt - - -def fahre(wurzel: Path, projekt: Path, extra_env: dict | None = None, tag: str = TAG, meldung: bool = False) -> dict: - befehl = [ - sys.executable, str(HIER / "abendkontrolle_wsl.py"), "--tag", tag, "--projekt", str(projekt), - "--kalender", str(wurzel / "kalender.json"), "--journal", str(wurzel / "journal.txt"), - "--dienste", str(wurzel / "dienste.json"), "--ohne-kerzen", - ] - if not meldung: - befehl.append("--ohne-meldung") - # Mit der Ersatz-Auswertung darf die Brokerzeile geprueft werden: Der Ersatz geht nicht ans Netz. - if not (extra_env and "ABENDKONTROLLE_AUSWERTUNG" in extra_env): - befehl.append("--ohne-broker") - env = {k: v for k, v in os.environ.items() if k != "ABENDKONTROLLE_AUSWERTUNG"} - env.update(extra_env or {}) - lauf = subprocess.run(befehl, capture_output=True, text=True, env=env, 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 erwarte(name: str, ergebnis: dict, *, befund: str | None, anzahl: int | None = None, exit_code: int = 0, - auch: tuple = (), nicht: tuple = (), fakt=None) -> None: - befunde = ergebnis.get("befunde", []) - alles = " | ".join(befunde) - ok = True - if befund is None and befunde: - ok = False - if befund is not None and not any(befund in b for b in befunde): - ok = False - if any(teil not in alles for teil in auch) or any(teil in alles for teil in nicht): - ok = False - if anzahl is not None and len(befunde) != anzahl: - ok = False - if ergebnis.get("_exit", ergebnis.get("exit")) != exit_code: - ok = False - if fakt is not None: - try: - ok = ok and bool(fakt(ergebnis.get("fakten") or {}, ergebnis)) - except Exception: - ok = False - zeile = f"{'OK ' if ok else 'FEHL'} {name}: {len(befunde)} Befund(e) {befunde[:3]} meldung={(ergebnis.get('fakten') or {}).get('meldung')}" - print(zeile) - if not ok: - abweichungen.append(zeile) - - -def wsl_faelle() -> None: - def fall(name, vorbereiten, *, befund, anzahl=None, exit_code=0, env=None, tag=TAG, meldung=False, - auch=(), nicht=(), fakt=None): - with tempfile.TemporaryDirectory() as tmp: - wurzel = Path(tmp) - projekt = baue_projekt(wurzel) - extra = vorbereiten(wurzel, projekt) - umgebung = {**(env or {}), **(extra if isinstance(extra, dict) else {})} - erwarte(name, fahre(wurzel, projekt, umgebung, tag, meldung), befund=befund, anzahl=anzahl, - exit_code=exit_code, auch=auch, nicht=nicht, fakt=fakt) - - nichts = lambda w, p: None # noqa: E731 - prot = lambda p: p / "artifacts" / "paper" / f"{TAG}T131023-paper.jsonl" # noqa: E731 - - def schreibe(pfad: Path, text: str) -> None: - pfad.write_text(text, encoding="utf-8") - - def zeilen_aendern(p: Path, aendern) -> None: - zeilen = prot(p).read_text(encoding="utf-8").splitlines() - schreibe(prot(p), "\n".join(aendern(zeilen)) + "\n") - - fall("Grundlinie", nichts, befund=None, anzahl=0) - # 1 Kalender - # Ein Feiertag ohne Protokoll: kein Befund. Haelt die Kontrolle ihn faelschlich fuer einen Handelstag, - # fehlt das Protokoll und es gibt einen -- so trennt der Fall "kein Handelstag" von "immer Handelstag". - def feiertag(inhalt): - def vorbereiten(w, p): - schreibe(w / "kalender.json", inhalt) - prot(p).unlink() - return vorbereiten - fall("Kalender leer (Feiertag, Wochenende)", feiertag("[]"), befund=None, anzahl=0) - fall("Kalender nennt nur einen anderen Tag", feiertag(json.dumps([{"date": "2026-09-22"}])), befund=None, anzahl=0) - fall("Kalender als Objekt", lambda w, p: schreibe(w / "kalender.json", "{}"), befund="Kalender in unerwarteter Form") - fall("Kalender Eintrag ohne date, erste Position", - lambda w, p: schreibe(w / "kalender.json", json.dumps([{"open": "09:30"}] + KALENDER_TAG)), - befund="Kalender in unerwarteter Form") - fall("Kalender Eintrag ohne date, letzte Position", - lambda w, p: schreibe(w / "kalender.json", json.dumps(KALENDER_TAG + [{"date": 5}])), - befund="Kalender in unerwarteter Form") - fall("Kalender-Datei fehlt", lambda w, p: (w / "kalender.json").unlink(), befund="Kalender, und damit") - fall("Kalender kein JSON", lambda w, p: schreibe(w / "kalender.json", ""), befund="Kalender, und damit") - # 2 Protokoll - fall("kein Protokoll", lambda w, p: prot(p).unlink(), befund="der Runner lief nicht") - fall("nur Probeprotokoll", lambda w, p: prot(p).rename(prot(p).with_name(f"{TAG}T131023-probe.jsonl")), - befund="der Runner lief nicht") - # 6 Handel - fall("nie aktiv", - lambda w, p: zeilen_aendern(p, lambda z: [s.replace('"killswitch": "aktiv"', '"killswitch": "angehalten"') for s in z]), - befund="Tag ohne Handel") - fall("kaputte Zeile vorne", lambda w, p: zeilen_aendern(p, lambda z: [z[0], "{kaputt", *z[1:]]), befund="nicht lesbar") - fall("kaputte Zeile hinten", lambda w, p: zeilen_aendern(p, lambda z: [*z, "[1, 2]"]), befund="nicht lesbar") - # 3 Journal - fall("Journal ohne Ende", lambda w, p: schreibe(w / "journal.txt", "gestartet\n"), befund="keine Zeile 'Ende") - fall("Journal leer", lambda w, p: schreibe(w / "journal.txt", ""), befund="keine Zeile 'Ende") - fall("Journal fehlt", lambda w, p: (w / "journal.txt").unlink(), befund="Journal nicht geprueft") - fall("Exit 4", lambda w, p: schreibe(w / "journal.txt", JOURNAL_GUT.replace("Exit 0", "Exit 4")), befund="Exit 4") - fall("Exit 4 im ersten von zwei Laeufen", - lambda w, p: schreibe(w / "journal.txt", JOURNAL_GUT.replace("Exit 0", "Exit 4") + JOURNAL_GUT), befund="Lauf 1 von 2") - fall("Exit 4 im letzten von zwei Laeufen", - lambda w, p: schreibe(w / "journal.txt", JOURNAL_GUT + JOURNAL_GUT.replace("Exit 0", "Exit 4")), befund="Lauf 2 von 2") - fall("Grund stoerung", lambda w, p: schreibe(w / "journal.txt", JOURNAL_GUT.replace("Ende: boersenschluss", "Ende: stoerung")), - befund="nicht mit boersenschluss") - fall("Position 10", lambda w, p: schreibe(w / "journal.txt", JOURNAL_GUT.replace("Position 0", "Position 10")), - befund="Position 10") - fall("Failed with result", - lambda w, p: schreibe(w / "journal.txt", JOURNAL_GUT + "trading-paper.service: Failed with result 'exit-code'.\n"), - befund="Failed with result") - # 4 Auswertung - fall("Orderzeile nicht gefuellt", - lambda w, p: zeilen_aendern(p, lambda z: [s.replace('"status": "filled"', '"status": "canceled"', 1) if '"art": "order"' in s else s for s in z]), - befund="nicht gefuellt") - fall("Schlussposition 10 im Protokoll", - lambda w, p: zeilen_aendern(p, lambda z: [s.replace('"position": 0', '"position": 10') if '"art": "ende"' in s else s for s in z]), - befund="Schlussposition ungleich null") - # 5 Broker (Ersatz-Auswertung, kein Netz) - ersatz = HIER / "abendkontrolle_ersatz_auswertung.py" - fall("Broker 2 offen", nichts, befund="2 offene Position", - env={"ABENDKONTROLLE_AUSWERTUNG": str(ersatz), "ERSATZ_AUSGABE": "offene positionen beim broker jetzt: 2"}, - ) - fall("Broker 0 offen", nichts, befund=None, anzahl=0, - env={"ABENDKONTROLLE_AUSWERTUNG": str(ersatz), "ERSATZ_AUSGABE": "offene positionen beim broker jetzt: 0"}) - fall("Auswertung Exit 3", nichts, befund="Exit 3", - env={"ABENDKONTROLLE_AUSWERTUNG": str(ersatz), "ERSATZ_AUSGABE": "VORAUSSETZUNG FEHLT: x", "ERSATZ_EXIT": "3"}) - fall("Auswertung Kontowert in BEFUND wird maskiert", nichts, befund="kontowert [x]", - env={"ABENDKONTROLLE_AUSWERTUNG": str(ersatz), "ERSATZ_AUSGABE": "BEFUND: kontowert 12345.67 zu tief"}) - # 7 Bericht - fall("Bericht fehlt", lambda w, p: (p / "data" / "paper" / "auswertungen" / f"{TAG}.md").unlink(), befund="Tagesbericht") - # 8 Dienste - fall("Dashboard inaktiv (erste Position)", - lambda w, p: schreibe(w / "dienste.json", json.dumps({**DIENSTE_GUT, "trading-dashboard.service": "failed"})), - befund="trading-dashboard.service ist nicht aktiv") - fall("Auswertungs-Zeitgeber fehlt (letzte Position)", - lambda w, p: schreibe(w / "dienste.json", json.dumps({k: v for k, v in DIENSTE_GUT.items() if "auswertung" not in k})), - befund="trading-auswertung.timer ist nicht aktiv") - fall("Dienste als Liste", lambda w, p: schreibe(w / "dienste.json", "[]"), befund="Dienste: Zustand nicht lesbar") - # 9 Killswitch - def killswitch(zustand): - def vorbereiten(w, p): - with sqlite3.connect(p / "data" / "registry.db") as db: - if zustand is None: - db.execute("DELETE FROM killswitch_zustand") - else: - db.execute("UPDATE killswitch_zustand SET zustand = ?", (zustand,)) - return vorbereiten - fall("Killswitch gesperrt", killswitch("gesperrt"), befund="gesperrt") - fall("Killswitch ohne Zeile", killswitch(None), befund="kein Killswitch-Zustand") - fall("Register fehlt", lambda w, p: (p / "data" / "registry.db").unlink(), befund="registry.db fehlt") - # 3 Ende je Lauf: mittags abgebrochen, der zweite Lauf bleibt angehalten (Befund des Pruefers W2) - def mittags_abgebrochen(w, p): - zeilen = prot(p).read_text(encoding="utf-8").splitlines() - takte = [z for z in zeilen if '"art": "takt"' in z] - ende = json.loads(next(z for z in zeilen if '"art": "ende"' in z)) - lauf1 = [zeilen[0], *takte[:30], json.dumps({**ende, "grund": "abbruch"})] - lauf2 = [zeilen[0], *[z.replace('"killswitch": "aktiv"', '"killswitch": "angehalten"') for z in takte[30:]], json.dumps(ende)] - schreibe(prot(p), "\n".join(lauf1) + "\n") - schreibe(p / "artifacts" / "paper" / f"{TAG}T160000-paper.jsonl", "\n".join(lauf2) + "\n") - schreibe(w / "journal.txt", JOURNAL_GUT.replace("Ende: boersenschluss", "Ende: abbruch") + JOURNAL_GUT) - fall("mittags abgebrochen, danach angehalten", mittags_abgebrochen, befund="Lauf endete mit abbruch", - auch=("Lauf 1 von 2) endete nicht mit boersenschluss",)) - - # Lauf nach Boersenschluss (WSL-Neustart abends): kein Befund (Befund des Pruefers W3) - def nach_schluss(w, p): - kopf = prot(p).read_text(encoding="utf-8").splitlines()[0] - ende = {"art": "ende", "zeit": f"{TAG}T21:06:40+00:00", "grund": "wartezeit", "fehler": None, "position": 0, - "orders": 0, "killswitch": "angehalten", "schritte": 0} - schreibe(p / "artifacts" / "paper" / f"{TAG}T210500-paper.jsonl", kopf + "\n" + json.dumps(ende) + "\n") - schreibe(w / "journal.txt", JOURNAL_GUT + "Ende: wartezeit, Position 0, Orders 0, Killswitch angehalten, Exit 3\n") - fall("Lauf nach Boersenschluss (wartezeit, Exit 3)", nach_schluss, befund=None, anzahl=0) - fall("nur ein Lauf mit wartezeit an einem Handelstag", lambda w, p: ( - nach_schluss(w, p), prot(p).unlink(), - schreibe(w / "journal.txt", "Ende: wartezeit, Position 0, Orders 0, Killswitch angehalten, Exit 3\n")), - befund="keine Zeile 'Ende", auch=("kein Lauf des scharfen Runners in der Handelszeit",)) - # 9 Killswitch: unbekannter Zustand, und eine Frage, die scheitert, haelt die anderen nicht auf - fall("Killswitch unbekannter Zustand", killswitch("kaputt"), befund="unbekanntem Zustand") - - def register_ohne_tabelle(w, p): - (p / "data" / "registry.db").unlink() - sqlite3.connect(p / "data" / "registry.db").close() - (p / "data" / "paper" / "auswertungen" / f"{TAG}.md").unlink() - fall("Register ohne Tabelle, Bericht fehlt", register_ohne_tabelle, befund="Killswitch nicht geprueft", - auch=("Tagesbericht",)) - # Maske auch ohne Geldwort (Befund des Pruefers W4) - fall("Maske ohne Geldwort", nichts, befund="Wert", - env={"ABENDKONTROLLE_AUSWERTUNG": str(ersatz), - "ERSATZ_AUSGABE": "BEFUND: Wert 100123.45 und 172.3456 und -4.56 USD\noffene positionen beim broker jetzt: 0"}, - nicht=("100123", "172.3456", "4.56")) - - # Meldeweg (Befund des Pruefers B1): ein Fehlschlag darf nicht als "gesendet" stehen - def mit_melder(port): - def vorbereiten(w, p): - 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") - schreibe(p / "config" / "paper.toml", text.replace(anker, f'ntfy_url = "http://127.0.0.1:{port}"\nklartext_lokal = true')) - schreibe(w / "secrets.toml", '[ntfy]\naktiv = true\nthema = "abendkontrolle-angriff"\n') - (p / "data" / "paper" / "auswertungen" / f"{TAG}.md").unlink() # ein Befund, damit gemeldet wird - return {"TRADING_GEHEIMNISSE": str(w / "secrets.toml")} - return vorbereiten - fall("Meldung scheitert (niemand hoert)", mit_melder(9), befund="Tagesbericht", meldung=True, - fakt=lambda f, e: str(f.get("meldung", "")).startswith("nicht gesendet")) - - with tempfile.TemporaryDirectory() as tmp: - mitschrift = Path(tmp) / "mitschrift.jsonl" - port = 18765 - 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) - fall("Meldung kommt an", mit_melder(port), befund="Tagesbericht", meldung=True, - fakt=lambda f, e: f.get("meldung") == "gesendet" and "Abendkontrolle" in mitschrift.read_text(encoding="utf-8")) - finally: - server.terminate() - server.wait(timeout=10) - - # Bedienfehler - fall("Tag falsch", nichts, befund="abgebrochen", exit_code=2, tag="2026-13-40") - - -# --- Windows-Teil -------------------------------------------------------------------------------- - -def abdichten(ak) -> list: - """Sperrt die echten Wege von abendkontrolle.py fuer die Windows-Faelle (Plankritik DH, 2026-09-26). - - Die Faelle laufen mit --ohne-wsl; bricht ein Mutant diesen Schalter, liefe sonst der echte - WSL-Teil gegen den echten Projektordner, und seit Paket DH ssh zum Docker-Host. wsl.exe und - ssh.exe zeigen ins Leere, fahre_wsl und fahre_dockerhost schreiben jeden Aufruf mit und - lehnen ab; jeder Aufruf ist eine Abweichung. - """ - gerufen = [] - abdichten.erwartet = False - - def verboten(name): - def weg(*argumente, **benannte): - gerufen.append(name) - zeile = f"FEHL echter Weg {name} gerufen: {argumente}" - abweichungen.append(zeile) - if not abdichten.erwartet: - print(zeile) - raise RuntimeError(f"echter Weg {name} in den Angriffsfaellen") - - weg.sperre = True - return weg - - leer = Path(tempfile.mkdtemp(prefix="abendkontrolle-angriffe-kein-weg-")) - atexit.register(shutil.rmtree, leer, True) # der leere Ordner bleibt nicht im Temp liegen - ak.WSL = str(leer / "wsl.exe") - ak.SSH = str(leer / "ssh.exe") - ak.WSL_SKRIPT = "/nicht-vorhanden/abendkontrolle_wsl.py" - ak.fahre_wsl = verboten("fahre_wsl") - ak.fahre_dockerhost = verboten("fahre_dockerhost") - return gerufen - - -def windows_faelle() -> None: - sys.path.insert(0, str(HIER)) - import abendkontrolle as ak - from datetime import datetime - - gerufen = abdichten(ak) - # Die Abdichtung greift: Ohne --ohne-wsl nimmt main den WSL-Weg, und der muss als Abweichung - # ankommen, nicht als Lauf. Die erwartete Abweichung wird danach wieder entfernt. - vorher = len(abweichungen) - abdichten.erwartet = True - with tempfile.TemporaryDirectory() as tmp: - (Path(tmp) / "Inbox").mkdir() - ak.main(["--vault", tmp, "--tag", TAG, "--teil", "alpaca", "--jetzt", "2026-09-23T22:45:00"]) - abdichten.erwartet = False - # Beleg fuer die uebrigen Zeilen von abdichten: beide Programme und das Skript ins Leere, - # fahre_dockerhost gesperrt. - gesperrt = (not Path(ak.WSL).exists() and not Path(ak.SSH).exists() - and ak.WSL_SKRIPT.startswith("/nicht-vorhanden/") - and getattr(ak.fahre_dockerhost, "sperre", False)) - ok = gesperrt and gerufen == ["fahre_wsl"] and len(abweichungen) == vorher + 1 - del abweichungen[vorher:] - del gerufen[:] - zeile = f"{'OK ' if ok else 'FEHL'} W Abdichtung: der echte WSL-Weg kommt als Abweichung an" - print(zeile) - if not ok: - abweichungen.append(zeile) - - gut = {"tag": TAG, "befunde": [], "hinweise": ["auto_fortsetzen: fortgesetzt"], "exit": 0, - "fakten": {"handelstag": True, "ende": {"grund": "boersenschluss", "exit": "0", "orders": "79", "position": "0"}, - "laeufe": [{"takte_aktiv": 375}], "broker_offen": 0, "bericht": True, - "killswitch": {"zustand": "aktiv", "ausloeser": "automatik"}}} - - def lauf(name, notiz_vorher, ergebnis, *, jetzt="2026-09-23T22:45:00", pruefe): - with tempfile.TemporaryDirectory() as tmp: - vault = Path(tmp) - (vault / "Inbox").mkdir() - notiz = vault / "Inbox" / f"{TAG}.md" - if notiz_vorher is not None: - notiz.write_bytes(notiz_vorher if isinstance(notiz_vorher, bytes) else notiz_vorher.encode("utf-8")) - eingabe = vault / "ergebnis.json" - eingabe.write_text(ergebnis if isinstance(ergebnis, str) else json.dumps(ergebnis), encoding="utf-8") - code = ak.main(["--vault", str(vault), "--tag", TAG, "--ohne-wsl", str(eingabe), "--jetzt", jetzt]) - text = notiz.read_bytes().decode("utf-8", errors="replace") if notiz.exists() else "" - marke = (vault / ak.ABLAGE_TEIL / f"{TAG}.json").exists() - try: - ok, grund = pruefe(code, text, marke) - except Exception as ausnahme: # der Treiber selbst bricht nicht ab - ok, grund = False, f"Pruefung warf {ausnahme!r}; Notiz {text[-120:]!r}" - zeile = f"{'OK ' if ok else 'FEHL'} W {name}: {grund}" - print(zeile) - if not ok: - abweichungen.append(zeile) - - kopf = "---\ntype: daily\n---\n\n# 2026-09-23\n\n" - lauf("neue Notiz, ohne Befund", None, gut, pruefe=lambda c, t, m: ( - c == 0 and m and "## Agentenprotokoll" in t and "ohne Befund" in t and "type: daily" in t, f"code {c} marke {m}")) - lauf("Abschnitt vor einer weiteren Ueberschrift", kopf + "## Agentenprotokoll\n\n- alt\n\n## Danach\n\ntext\n", gut, - pruefe=lambda c, t, m: (t.index("ohne Befund") < t.index("## Danach") and "- alt\n- 22:45" in t - and "\n\n## Danach" in t and "\n\n\n" not in t, repr(t[-80:]))) - lauf("Abschnitt am Ende", kopf + "## Agentenprotokoll\n\n- alt\n", gut, - pruefe=lambda c, t, m: (t.endswith(".\n - Hinweis: auto_fortsetzen: fortgesetzt\n") and "- alt\n- 22:45" in t, repr(t[-60:]))) - lauf("ohne Abschnitt", kopf + "## Anderes\n\nx\n", gut, - pruefe=lambda c, t, m: (t.count("## Agentenprotokoll") == 1 and t.index("## Anderes") < t.index("## Agentenprotokoll"), repr(t[-60:]))) - lauf("Abschnitt direkt vor naechster Ueberschrift", kopf + "## Agentenprotokoll\n## Danach\n", gut, - pruefe=lambda c, t, m: ("## Agentenprotokoll\n\n- 22:45" in t and "\n\n## Danach" in t, repr(t))) - lauf("CRLF-Notiz", (kopf + "## Agentenprotokoll\n\n- alt\n").replace("\n", "\r\n"), gut, - pruefe=lambda c, t, m: ("\n" not in t.replace("\r\n", ""), "gemischte Zeilenenden" if "\n" in t.replace("\r\n", "") else "ok")) - mit_befund = {**gut, "befunde": ["Runner endete mit Exit 4", "Beim Broker stehen 2 offene Position(en)"]} - lauf("mit Befund", None, mit_befund, pruefe=lambda c, t, m: ( - "**2 Befund(e)** #needs-review" in t and " - Runner endete mit Exit 4" in t and m, repr(t[-160:]))) - lauf("Ergebnis ohne befunde", None, {"tag": TAG}, pruefe=lambda c, t, m: ( - c == 1 and not m and "**gescheitert**" in t and "#needs-review" in t, f"code {c} marke {m}")) - lauf("Ergebnis kein JSON", None, "nicht json", pruefe=lambda c, t, m: (c == 1 and not m and "**gescheitert**" in t, f"code {c}")) - lauf("kein Handelstag", None, {"tag": TAG, "befunde": [], "hinweise": [], "fakten": {"handelstag": False}}, - pruefe=lambda c, t, m: ("kein Handelstag" in t and m, repr(t[-60:]))) - # Befunde des Pruefers: Ueberschrift mit Zusatz, Notiz ohne UTF-8, Abbruch mit JSON, Nachbardatei - lauf("Ueberschrift mit Zusatz", kopf + "## Agentenprotokoll — Sitzung 9\n\n- alt\n\n## Offene Punkte\n\n- x\n", gut, - pruefe=lambda c, t, m: (t.count("## Agentenprotokoll") == 1 and t.index("ohne Befund") < t.index("## Offene Punkte"), repr(t[-120:]))) - kaputt = "---\ntype: daily\n---\n\n# Tag äöü\n".encode("cp1252") - lauf("Notiz kein UTF-8", kaputt, mit_befund, pruefe=lambda c, t, m: (m and c == 1, f"code {c} marke {m}")) - lauf("Abbruch mit JSON (exit 2) wird wiederholt", None, - {"tag": TAG, "befunde": ["Abendkontrolle abgebrochen: X"], "hinweise": [], "fakten": {}, "exit": 2}, - pruefe=lambda c, t, m: (not m and "**1 Befund(e)**" in t and c == 1, f"code {c} marke {m}")) - with tempfile.TemporaryDirectory() as tmp: - vault = Path(tmp) - (vault / "Inbox").mkdir() - eingabe = vault / "e.json" - eingabe.write_text(json.dumps(gut), encoding="utf-8") - ak.main(["--vault", str(vault), "--tag", TAG, "--ohne-wsl", str(eingabe), "--jetzt", "2026-09-23T22:45:00"]) - rest = [p.name for p in (vault / "Inbox").iterdir() if not p.name.endswith(".md")] - ok = not rest - print(f"{'OK ' if ok else 'FEHL'} W keine Nachbardatei uebrig: {rest}") - if not ok: - abweichungen.append("Nachbardatei uebrig") - - # faellige_tage - from datetime import date - with tempfile.TemporaryDirectory() as tmp: - ablage = Path(tmp) - def tage(jetzt): - return ak.faellige_tage(datetime.fromisoformat(jetzt), ablage) - faelle = [ - ("Do vor 22:30: heute nicht", "2026-09-24T21:00", ["2026-09-24"], False), - ("Do nach 22:30: heute", "2026-09-24T22:45", ["2026-09-24"], True), - ("Mo frueh: Fr nachholen, Wochenende nicht", "2026-09-28T08:00", ["2026-09-25"], True), - ("Mo frueh: nichts vor ERSTER_TAG", "2026-09-28T08:00", ["2026-09-23"], False), - ] - for name, jetzt, erwartet, drin in faelle: - ergebnis = tage(jetzt) - ok = all((tag in ergebnis) == drin for tag in erwartet) and not any( - date.fromisoformat(t).weekday() >= 5 for t in ergebnis) - zeile = f"{'OK ' if ok else 'FEHL'} T {name}: {ergebnis}" - print(zeile) - if not ok: - abweichungen.append(zeile) - (ablage / "2026-09-25.json").write_text("{}", encoding="utf-8") - ergebnis = tage("2026-09-28T08:00") - ok = "2026-09-25" not in ergebnis - print(f"{'OK ' if ok else 'FEHL'} T Ergebnisdatei da: nicht erneut: {ergebnis}") - if not ok: - abweichungen.append("Ergebnisdatei ignoriert") - ergebnis = tage("2026-10-12T08:00") - ok = min(ergebnis) >= "2026-10-05" - print(f"{'OK ' if ok else 'FEHL'} T hoechstens sieben Tage zurueck: {ergebnis}") - if not ok: - abweichungen.append("Nachholfenster") - - -# --- IBKR-Instanz (Paket IB2), WSL-Teil ------------------------------------------------------------ - -IBKR_JOURNAL_GUT = ( - "Ende: boersenschluss, Position 0, Orders 2, Killswitch aktiv, Exit 0\n" - "trading-paper-ibkr.service: Deactivated successfully.\n" -) -# Erfundene Ausgabe von scripts/ibkr_pruefen.py lesen: ohne Kontonummer, ohne Betrag. -IBKR_KONTO_GUT = ( - "verbunden: Gateway 127.0.0.1:4004, Client-ID 12, Handelsmodus paper, Konto DU... (9 Zeichen), " - "vom Gateway bestaetigt\n" - "Konto: Kontowert, Bargeld und Kaufkraft gelesen (Betraege werden nicht ausgegeben)\n" - "Waehrung des Kontowerts: EUR\n" - "Positionen: 0\n" - "Offene Orders: 0\n" - "ERGEBNIS: gelesen\n" -) -# Eine erfundene Kontonummer in der Form der Paper-Konten, nur fuer die Maske. -KONTO_ERFUNDEN = "DU0000000" -KONTO_ERFUNDEN_LANG = "DUQ000000" -# 22:45 Berlin ist 16:45 New York (nach Boersenschluss), 16:00 Berlin ist 10:00 New York. -NACH_SCHLUSS = f"{TAG}T22:45:00+02:00" -IM_HANDEL = f"{TAG}T16:00:00+02:00" - - -def baue_ibkr(wurzel: Path) -> Path: - ordner = wurzel / "betrieb" - (ordner / "artifacts" / "paper").mkdir(parents=True) - (ordner / "data" / "paper").mkdir(parents=True) - (ordner / "data" / "paper" / "broker.txt").write_text("ibkr\n", encoding="utf-8") - shutil.copyfile(ECHTES_PROTOKOLL, ordner / "artifacts" / "paper" / f"{TAG}T131023-paper.jsonl") - with sqlite3.connect(ordner / "data" / "registry.db") as db: - db.execute("CREATE TABLE killswitch_zustand (id INTEGER PRIMARY KEY, zustand TEXT, seit TEXT, ausloeser TEXT)") - db.execute("CREATE TABLE killswitch_ereignisse (id INTEGER PRIMARY KEY AUTOINCREMENT, zeit TEXT, stufe TEXT, " - "ausloeser TEXT, grund TEXT, ergebnis TEXT, erfolgreich INTEGER)") - db.execute("INSERT INTO killswitch_zustand VALUES (1, 'aktiv', '2026-09-23T13:31:00+00:00', 'automatik')") - db.execute("INSERT INTO killswitch_ereignisse (zeit, stufe, ausloeser, grund, ergebnis, erfolgreich) " - "VALUES ('2026-09-23T13:31:00+00:00', 'fortsetzen', 'automatik', 'Tagesstart', '{}', 1)") - (wurzel / "kalender.json").write_text(json.dumps(KALENDER_TAG), encoding="utf-8") - (wurzel / "journal.txt").write_text(IBKR_JOURNAL_GUT, encoding="utf-8") - (wurzel / "dienste.json").write_text(json.dumps({"trading-paper-ibkr.timer": "active"}), encoding="utf-8") - (wurzel / "konto.json").write_text(json.dumps({"exit": 0, "text": IBKR_KONTO_GUT}), encoding="utf-8") - return ordner - - -def fahre_ibkr(wurzel: Path, ordner: Path, jetzt: str = NACH_SCHLUSS, tag: str = TAG, *, - projekt: Path | None = None, meldung: bool = False, env: dict | None = None) -> dict: - befehl = [ - sys.executable, str(HIER / "abendkontrolle_wsl.py"), "--teil", "ibkr", "--tag", tag, - "--ibkr-ordner", str(ordner), "--projekt", str(wurzel if projekt is None else projekt), - "--kalender", str(wurzel / "kalender.json"), "--journal", str(wurzel / "journal.txt"), - "--dienste", str(wurzel / "dienste.json"), "--ibkr-konto", str(wurzel / "konto.json"), "--jetzt", jetzt, - ] - 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 ibkr_wsl_faelle() -> None: - def fall(name, vorbereiten, *, befund, anzahl=None, exit_code=0, jetzt=NACH_SCHLUSS, tag=TAG, - auch=(), nicht=(), fakt=None): - with tempfile.TemporaryDirectory() as tmp: - wurzel = Path(tmp) - ordner = baue_ibkr(wurzel) - vorbereiten(wurzel, ordner) - erwarte("IBKR " + name, fahre_ibkr(wurzel, ordner, jetzt, tag), befund=befund, anzahl=anzahl, - exit_code=exit_code, auch=auch, nicht=nicht, fakt=fakt) - - nichts = lambda w, o: None # noqa: E731 - prot = lambda o: o / "artifacts" / "paper" / f"{TAG}T131023-paper.jsonl" # noqa: E731 - - def schreibe(pfad: Path, text: str) -> None: - pfad.write_text(text, encoding="utf-8") - - def konto(text: str, code: int = 0): - return lambda w, o: schreibe(w / "konto.json", json.dumps({"exit": code, "text": text})) - - def register(*befehle: str): - def vorbereiten(w, o): - with sqlite3.connect(o / "data" / "registry.db") as db: - for befehl in befehle: - db.execute(befehl) - return vorbereiten - - def zeilen_aendern(o: Path, aendern) -> None: - zeilen = prot(o).read_text(encoding="utf-8").splitlines() - schreibe(prot(o), "\n".join(aendern(zeilen)) + "\n") - - fall("Grundlinie", nichts, befund=None, anzahl=0, - fakt=lambda f, e: f.get("konto") == {"positionen": 0, "orders": 0, "exit": 0} and e.get("teil") == "ibkr") - # I2 No-Go-Tag: Zeitgeber aus und kein Protokoll ist leise; Zeitgeber an und kein Protokoll ist ein Befund. - def zeitgeber_aus(w, o): - prot(o).unlink() - schreibe(w / "dienste.json", json.dumps({"trading-paper-ibkr.timer": "inactive"})) - fall("Zeitgeber aus, kein Protokoll", zeitgeber_aus, befund=None, anzahl=0, - fakt=lambda f, e: f.get("nicht_gestartet") is True - and any("IBKR-Instanz heute nicht gestartet" in h for h in e.get("hinweise", []))) - fall("Zeitgeber an, kein Protokoll", lambda w, o: prot(o).unlink(), befund="die Instanz lief nicht") - fall("Zeitgeber aus, aber von Hand gelaufen", - lambda w, o: schreibe(w / "dienste.json", json.dumps({"trading-paper-ibkr.timer": "inactive"})), - befund=None, anzahl=0, fakt=lambda f, e: len(f.get("laeufe") or []) == 1 and not f.get("nicht_gestartet")) - fall("Kalender leer", lambda w, o: (schreibe(w / "kalender.json", "[]"), prot(o).unlink()), befund=None, anzahl=0) - # I3 Instanzmarke - fall("Marke fehlt", lambda w, o: (o / "data" / "paper" / "broker.txt").unlink(), befund="Instanzmarke") - fall("Marke alpaca", lambda w, o: schreibe(o / "data" / "paper" / "broker.txt", "alpaca\n"), - befund="nicht der Betriebsordner der IBKR-Instanz") - # I4 Register - fall("gesperrt nach Red Button", - register("UPDATE killswitch_zustand SET zustand = 'gesperrt', ausloeser = 'pascal'", - "INSERT INTO killswitch_ereignisse (zeit, stufe, ausloeser, grund, ergebnis, erfolgreich) " - "VALUES ('2026-09-23T15:00:00+00:00', 'glattstellen', 'pascal', 'Probe', '{}', 1)"), - befund="IBKR-Killswitch steht auf gesperrt", auch=("letztes Ereignis glattstellen von pascal",)) - fall("gesperrt ohne Ereignis", - register("UPDATE killswitch_zustand SET zustand = 'gesperrt'", "DELETE FROM killswitch_ereignisse"), - befund="IBKR-Killswitch steht auf gesperrt", auch=("ohne Ereignis im Register",)) - fall("Killswitch unbekannter Zustand", register("UPDATE killswitch_zustand SET zustand = 'kaputt'"), - befund="IBKR-Killswitch in unbekanntem Zustand") - fall("Register fehlt", lambda w, o: (o / "data" / "registry.db").unlink(), befund="IBKR-Register data/registry.db fehlt") - fall("Register ohne Ereignistabelle", register("DROP TABLE killswitch_ereignisse"), - befund="IBKR-Killswitch nicht geprueft") - # I5 Ende je Lauf, Handel, Schlussposition - fall("Lauf endete mit abbruch", - lambda w, o: zeilen_aendern(o, lambda z: [s.replace('"grund": "boersenschluss"', '"grund": "abbruch"') - if '"art": "ende"' in s else s for s in z]), - befund="Lauf endete mit abbruch") - fall("nie aktiv", - lambda w, o: zeilen_aendern(o, lambda z: [s.replace('"killswitch": "aktiv"', '"killswitch": "angehalten"') for s in z]), - befund="Tag ohne Handel") - # Das Journal meldet Position 0, das Protokoll eine Restposition: Die Schlussposition kommt aus dem - # Protokoll, unabhaengig vom Journal (Anforderung Nr. 5, "Position 0"). - fall("Restposition im Protokoll bei sauberem Journal", - lambda w, o: zeilen_aendern(o, lambda z: [s.replace('"position": 0', '"position": 10') - if '"art": "ende"' in s else s for s in z]), - befund="endete laut Protokoll mit Position 10", anzahl=1) - # Ein Lauf ohne Zeile ende: pruefe_laeufe ueberspringt ihn, im Teil ibkr meldet ihn - # pruefe_schlusspositionen (Nachkritik IB2). - fall("Lauf ohne Zeile ende", - lambda w, o: zeilen_aendern(o, lambda z: [s for s in z if '"art": "ende"' not in s]), - befund="Lauf ohne Zeile ende im Protokoll") - # I6 Journal - fall("Journal Exit 4", lambda w, o: schreibe(w / "journal.txt", IBKR_JOURNAL_GUT.replace("Exit 0", "Exit 4")), - befund="Exit 4") - fall("Journal ohne Ende", lambda w, o: schreibe(w / "journal.txt", "gestartet\n"), - befund="von trading-paper-ibkr.service an diesem Tag") - fall("Journal Failed with result", - lambda w, o: schreibe(w / "journal.txt", IBKR_JOURNAL_GUT + "trading-paper-ibkr.service: Failed with result 'exit-code'.\n"), - befund="Journal: trading-paper-ibkr.service endete mit 'Failed with result'") - fall("Journal Traceback", - lambda w, o: schreibe(w / "journal.txt", IBKR_JOURNAL_GUT + "Traceback (most recent call last):\n File x\nValueError: y\n"), - befund="zeigt 'Traceback") - fall("Journal Marke passt nicht zur Konfiguration", - lambda w, o: schreibe(w / "journal.txt", IBKR_JOURNAL_GUT + "Dieser Ordner ist die IBKR-Instanz, die Konfiguration " - "waehlt aber [broker] wahl = 'alpaca'. Nichts wird gebaut, das Alpaca-Konto bleibt unberuehrt.\n"), - befund="zeigt 'Nichts wird gebaut'") - # I7 Konto am Gateway - fall("Konto 2 Positionen", - konto(IBKR_KONTO_GUT.replace("Positionen: 0\n", "Positionen: 2\n AAPL.XNAS 10\n KO.XNYS 10\n")), - befund="Beim IBKR-Gateway stehen 2 Position(en)", nicht=("AAPL.XNAS 10", "KO.XNYS 10")) - fall("Konto 1 offene Order", konto(IBKR_KONTO_GUT.replace("Offene Orders: 0", "Offene Orders: 1")), - befund="Beim IBKR-Gateway stehen 1 offene Order(s)") - fall("Konto ohne Zeilen", konto("verbunden: Gateway 127.0.0.1:4004\nERGEBNIS: gelesen\n"), - befund="Konto am Gateway nicht gelesen") - fall("Konto Voraussetzung fehlt", konto("Voraussetzung fehlt: config/paper-ibkr.toml fehlt\n", 4), - befund="endete mit Exit 4", auch=("Voraussetzung fehlt",)) - fall("Konto BEFUND mit Kontonummer wird maskiert", - konto(IBKR_KONTO_GUT + f"BEFUND: Gateway meldet das Konto {KONTO_ERFUNDEN} als gesperrt\n", 1), - befund="[konto]", nicht=(KONTO_ERFUNDEN,)) - # Die echte Form hat drei Buchstaben vorn und sechs Ziffern (Pruefung IB2); erfunden, wie oben. - fall("Konto BEFUND mit Kontonummer in der echten Form wird maskiert", - konto(IBKR_KONTO_GUT + f"BEFUND: Gateway meldet das Konto {KONTO_ERFUNDEN_LANG} als gesperrt\n", 1), - befund="[konto]", nicht=(KONTO_ERFUNDEN_LANG,)) - fall("im Handelsfenster wird das Konto nicht gelesen", konto("kaputt", 9), befund=None, anzahl=0, jetzt=IM_HANDEL, - fakt=lambda f, e: "konto" not in f and any("nicht gelesen" in h for h in e.get("hinweise", []))) - # Bedienung - fall("Betriebsordner fehlt", lambda w, o: shutil.rmtree(o), befund="Betriebsordner der IBKR-Instanz fehlt", anzahl=1) - fall("Tag falsch", nichts, befund="abgebrochen", exit_code=2, tag="2026-13-40") - - # Meldeweg des Teils ibkr ohne --ohne-meldung, gegen den lokalen Probeserver: Titel beginnt mit - # "IBKR Abendkontrolle", Prioritaet hoch (Anforderung Nr. 5). Eine Zeile kennung in der kopierten - # config/paper.toml faellt heraus, sonst stuende sie vor dem Titel. - with tempfile.TemporaryDirectory() as tmp_server: - mitschrift = Path(tmp_server) / "mitschrift.jsonl" - port = 18766 - 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) - ordner = baue_ibkr(wurzel) - (ordner / "data" / "paper" / "broker.txt").unlink() # ein Befund, damit gemeldet wird - 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") - text = text.replace(anker, f'ntfy_url = "http://127.0.0.1:{port}"\nklartext_lokal = true') - text = "\n".join(z for z in text.split("\n") if not z.strip().startswith("kennung")) - schreibe(projekt / "config" / "paper.toml", text) - schreibe(wurzel / "secrets.toml", '[ntfy]\naktiv = true\nthema = "abendkontrolle-angriff"\n') - ergebnis = fahre_ibkr(wurzel, ordner, projekt=projekt, 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] - stufen = [z.get("priority") for z in zeilen] - gesendet = (ergebnis.get("fakten") or {}).get("meldung") - ok = (gesendet == "gesendet" and len(zeilen) == 1 and titel[0].startswith("IBKR Abendkontrolle") - and stufen == ["high"]) - zeile = (f"{'OK ' if ok else 'FEHL'} IBKR Meldung mit Titel IBKR und Prioritaet hoch: " - f"titel={titel} prioritaet={stufen} meldung={gesendet}") - print(zeile) - if not ok: - abweichungen.append(zeile) - finally: - server.terminate() - server.wait(timeout=10) - - -# --- IBKR-Instanz (Paket IB2), Windows-Teil ---------------------------------------------------------- - -def ibkr_windows_faelle() -> None: - sys.path.insert(0, str(HIER)) - import abendkontrolle as ak - from datetime import datetime - - abdichten(ak) - - gut = {"tag": TAG, "teil": "ibkr", "befunde": [], "hinweise": [], "exit": 0, - "fakten": {"handelstag": True, - "ende": {"grund": "boersenschluss", "exit": "0", "orders": "40", "position": "0"}, - "laeufe": [{"takte_aktiv": 375}], "konto": {"positionen": 0, "orders": 0, "exit": 0}, - "killswitch": {"zustand": "aktiv", "ausloeser": "automatik"}}} - alpaca = {"tag": TAG, "befunde": [], "hinweise": [], "exit": 0, - "fakten": {"handelstag": True, - "ende": {"grund": "boersenschluss", "exit": "0", "orders": "79", "position": "0"}, - "laeufe": [{"takte_aktiv": 375}], "broker_offen": 0, "bericht": True, - "killswitch": {"zustand": "aktiv", "ausloeser": "automatik"}}} - - def pruefe_fall(name, ok, grund): - zeile = f"{'OK ' if ok else 'FEHL'} W IBKR {name}: {grund}" - print(zeile) - if not ok: - abweichungen.append(zeile) - - def lauf(name, ergebnis, *, teil="ibkr", pruefe): - with tempfile.TemporaryDirectory() as tmp: - vault = Path(tmp) - (vault / "Inbox").mkdir() - eingabe = vault / "ergebnis.json" - eingabe.write_text(ergebnis if isinstance(ergebnis, str) else json.dumps(ergebnis), encoding="utf-8") - argumente = ["--vault", str(vault), "--tag", TAG, "--ohne-wsl", str(eingabe), "--jetzt", "2026-09-23T22:45:00"] - if teil is not None: - argumente += ["--teil", teil] - code = ak.main(argumente) - notiz = vault / "Inbox" / f"{TAG}.md" - text = notiz.read_text(encoding="utf-8") if notiz.exists() else "" - eigene = (vault / ak.ABLAGE_TEIL / f"{TAG}-ibkr.json").exists() - fremde = (vault / ak.ABLAGE_TEIL / f"{TAG}.json").exists() - try: - ok, grund = pruefe(code, text, eigene, fremde) - except Exception as ausnahme: # der Treiber selbst bricht nicht ab - ok, grund = False, f"Pruefung warf {ausnahme!r}" - pruefe_fall(name, ok, grund) - - lauf("ohne Befund: eigene Zeile, eigene Datei", gut, pruefe=lambda c, t, e, f: ( - c == 0 and e and not f and "— Abendkontrolle IBKR (automatisch): ohne Befund" in t - and "Konto beim Gateway: 0 Position(en), 0 offene Order(s)" in t - and "Abendkontrolle Trading Plattform" not in t, f"code {c} eigene {e} fremde {f} {t[-160:]!r}")) - lauf("mit Befund", {**gut, "befunde": ["Beim IBKR-Gateway stehen 2 Position(en)"]}, pruefe=lambda c, t, e, f: ( - "Abendkontrolle IBKR (automatisch): **1 Befund(e)** #needs-review" in t - and " - Beim IBKR-Gateway stehen 2 Position(en)" in t and e, repr(t[-200:]))) - lauf("nicht gestartet ist leise", - {"tag": TAG, "teil": "ibkr", "befunde": [], "exit": 0, - "hinweise": ["IBKR-Instanz heute nicht gestartet (trading-paper-ibkr.timer: inactive)"], - "fakten": {"handelstag": True, "nicht_gestartet": True, "zeitgeber": "inactive"}}, - pruefe=lambda c, t, e, f: ("Abendkontrolle IBKR (automatisch): IBKR-Instanz heute nicht gestartet (Zeitgeber aus)." in t - and "#needs-review" not in t and e and c == 0, repr(t[-160:]))) - lauf("kein Handelstag", {"tag": TAG, "teil": "ibkr", "befunde": [], "hinweise": [], "exit": 0, - "fakten": {"handelstag": False}}, - pruefe=lambda c, t, e, f: ("Abendkontrolle IBKR (automatisch): kein Handelstag" in t and e, repr(t[-120:]))) - lauf("Abbruch wird wiederholt", {"tag": TAG, "teil": "ibkr", "befunde": ["Abendkontrolle abgebrochen: X"], - "hinweise": [], "fakten": {}, "exit": 2}, - pruefe=lambda c, t, e, f: (c == 1 and not e and "**1 Befund(e)**" in t, f"code {c} eigene {e}")) - lauf("gescheitert", "nicht json", pruefe=lambda c, t, e, f: ( - c == 1 and not e and "Abendkontrolle IBKR (automatisch): **gescheitert**" in t, f"code {c} {t[-120:]!r}")) - # Ein Ergebnis des falschen Teils (etwa von einem WSL-Teil ohne IB2) landet nicht in der Datei der Instanz. - lauf("Ergebnis eines anderen Teils", {**gut, "teil": "alpaca"}, pruefe=lambda c, t, e, f: ( - c == 1 and not e and not f and "Abendkontrolle IBKR (automatisch): **gescheitert**" in t, - f"code {c} eigene {e} fremde {f}")) - lauf("ohne --teil mit --ohne-wsl nur Alpaca", alpaca, teil=None, pruefe=lambda c, t, e, f: ( - f and not e and "Abendkontrolle IBKR" not in t and "Abendkontrolle Trading Plattform" in t, - f"eigene {e} fremde {f}")) - - # Beide Teile in derselben Notiz, Alpaca zuerst, je mit eigener Ergebnisdatei. - with tempfile.TemporaryDirectory() as tmp: - vault = Path(tmp) - (vault / "Inbox").mkdir() - (vault / "a.json").write_text(json.dumps(alpaca), encoding="utf-8") - (vault / "i.json").write_text(json.dumps(gut), encoding="utf-8") - gemeinsam = ["--vault", str(vault), "--tag", TAG, "--jetzt", "2026-09-23T22:45:00"] - ak.main(gemeinsam + ["--teil", "alpaca", "--ohne-wsl", str(vault / "a.json")]) - ak.main(gemeinsam + ["--teil", "ibkr", "--ohne-wsl", str(vault / "i.json")]) - text = (vault / "Inbox" / f"{TAG}.md").read_text(encoding="utf-8") - stelle_a = text.find("Abendkontrolle Trading Plattform") - stelle_i = text.find("Abendkontrolle IBKR") - ok = (0 <= stelle_a < stelle_i and text.count("## Agentenprotokoll") == 1 - and (vault / ak.ABLAGE_TEIL / f"{TAG}.json").exists() - and (vault / ak.ABLAGE_TEIL / f"{TAG}-ibkr.json").exists()) - pruefe_fall("beide Teile in einer Notiz", ok, f"alpaca {stelle_a} ibkr {stelle_i}") - - # Relativ zu IBKR_ERSTER_TAG: Den Wert setzt der Koordinator vor Welle 1 (Planuebersicht IB2). - erster = ak.IBKR_ERSTER_TAG.isoformat() - pruefe_fall("IBKR_ERSTER_TAG ist ein Werktag nicht vor ERSTER_TAG", - ak.IBKR_ERSTER_TAG.weekday() < 5 and ak.IBKR_ERSTER_TAG >= ak.ERSTER_TAG, erster) - - # Ohne --tag: am ersten Tag vor 22:30 ist fuer den Teil ibkr nichts faellig, auch kein Tag davor. - with tempfile.TemporaryDirectory() as tmp: - vault = Path(tmp) - (vault / "Inbox").mkdir() - (vault / "i.json").write_text(json.dumps(gut), encoding="utf-8") - ak.main(["--vault", str(vault), "--teil", "ibkr", "--ohne-wsl", str(vault / "i.json"), "--jetzt", f"{erster}T08:00:00"]) - dateien = sorted(p.name for p in (vault / ak.ABLAGE_TEIL).glob("*-ibkr.json")) - pruefe_fall("ohne --tag nichts vor IBKR_ERSTER_TAG", dateien == [], str(dateien)) - - # faellige Tage je Teil, direkt - with tempfile.TemporaryDirectory() as tmp: - ablage = Path(tmp) - - def ibkr_tage(jetzt): - return ak.faellige_tage(datetime.fromisoformat(jetzt), ablage, erster_tag=ak.IBKR_ERSTER_TAG, - endung=ak.IBKR_ENDUNG) - - ergebnis = ibkr_tage(f"{erster}T08:00") - pruefe_fall("faellig: nichts vor IBKR_ERSTER_TAG", ergebnis == [], str(ergebnis)) - ergebnis = ibkr_tage(f"{erster}T22:45") - pruefe_fall("faellig: der erste Tag nach 22:30", erster in ergebnis, str(ergebnis)) - (ablage / "2026-09-29-ibkr.json").write_text("{}", encoding="utf-8") - ibkr = ibkr_tage("2026-09-30T08:00") - alpaca_tage = ak.faellige_tage(datetime.fromisoformat("2026-09-30T08:00"), ablage) - pruefe_fall("faellig: eigene Ergebnisdatei je Teil", "2026-09-29" not in ibkr and "2026-09-29" in alpaca_tage, - 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", "", 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) diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_dockerhost_angriffe.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle_dockerhost_angriffe.py deleted file mode 100644 index 54e8f82..0000000 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle_dockerhost_angriffe.py +++ /dev/null @@ -1,1811 +0,0 @@ -"""Angriffsfaelle fuer die Quellen wsl und dockerhost von abendkontrolle.py (Paket DH), unter Windows, ohne Netz. - -Koordinator-Werkzeug, nicht fuers Repo. Ersetzt ssh.exe durch eine .cmd-Attrappe, die ein kleines -Python-Skript startet; der Vault ist ein Wegwerf-Ordner. Die Attrappe schreibt jeden Aufruf mit -und antwortet je nach ANGRIFF_ART mit einem Ergebnis des angefragten Teils (JJJJ-MM-TT oder -JJJJ-MM-TT-ibkr), mit Exit 3 (noch nichts da), mit Muell oder mit einem Ergebnis des falschen -Teils. Der Treiber faengt je Fall auch SystemExit: Gegen ein abendkontrolle.py ohne --quelle -bricht argparse damit ab, und das soll ein Fall sein, nicht das Ende des Laufs. -Abgedichtet (Plankritik DH, 2026-09-26): WSL zeigt in jedem Lauf ins Leere, und fahre_wsl ist -ohne eigene Attrappe des Falls eine Attrappe, die ablehnt; ein Aufruf des echten Wegs ist eine -Abweichung, kein Lauf. Zwei Faelle belegen beide Sperren. -Seit Paket E9 (Sicherung): Die Attrappe kennt auch sicherung-summe, sicherung und -sicherung-bestaetigt, so streng wie abendkontrolle-lesen.sh (Task 724: Formen, Exit 0, 1, 2, 3, 4), -gegen eine Host-Ablage im Wegwerf-Ordner; ANGRIFF_SICHERUNG stoert sie gezielt. Abgedichtet: -lauf() setzt SICHERUNG_ERSTER_TAG auf None und SICHERUNG_ZIEL in den Wegwerf-Ordner; -sicherungslauf() lenkt mit --sicherung-ziel um, und das Ziel der Konstante muss dort leer bleiben. -Nichts landet unter D:\\Trading-Sicherung. -Seit Paket NT (Quorum 4): ANGRIFF_HOST_WEG= laesst die Attrappe jeden Aufruf nach den ersten n mit -Exit 255 enden wie ssh ohne Host; ANGRIFF_SICHERUNG=langsam haelt jeden Sicherungsbefehl 5 s auf, -ANGRIFF_SICHERUNG=archiv_unlesbar endet beim Archiv wie abendkontrolle-lesen.sh ohne Leserecht. -Seit Paket NR (kritische Pruefung NT, Befund 3): lauf_log_sperren legt an der Stelle von lauf.log einen -Ordner an, und vier Faelle zeigen, dass Ergebnis, Notizzeilen und Sicherung nicht daran haengen. Seit der -Plankritik NR drei weitere: ein Fehler in logge, der kein OSError ist, eine Ablage, die sich nicht anlegen -laesst, und eine Ergebnisdatei, die sich nicht schreiben laesst. -Aufruf: python abendkontrolle_dockerhost_angriffe.py [--mutanten] -Exit 0 ohne Abweichung, 2 mit Abweichung. --mutanten faehrt MUTANTEN auf Kopien von -abendkontrolle.py und MUTANTEN_AUFGABE auf Kopien von abendkontrolle_einrichten.ps1: Jeder genannte -Fall muss rot werden; Exit 0, wenn keiner ueberlebt, sonst 2. Die .ps1 liest der Treiber nur als Text. -""" - -import contextlib -import hashlib -import importlib.util -import io -import json -import os -import sys -import tempfile -from datetime import date -from datetime import datetime -from pathlib import Path - -HIER = Path(__file__).resolve().parent -# Seit Paket E9: das geladene Werkzeug; im Modus --mutanten eine kaputte Kopie (mutanten()). -QUELLE = HIER / "abendkontrolle.py" -# Seit der Plankritik Runde 1 von E9: das Skript der Aufgabe, nur als Text gelesen; im Modus -# --mutanten auch hier eine kaputte Kopie. -AUFGABE = HIER / "abendkontrolle_einrichten.ps1" - -ATTRAPPE_PY = r''' -import json -import os -import sys - -with open(os.environ["ANGRIFF_PROTOKOLL"], "a", encoding="utf-8") as datei: - datei.write(json.dumps(sys.argv[1:]) + "\n") -# Seit Paket NT: Nach ANGRIFF_HOST_WEG Aufrufen ist der Host weg; ssh selbst endet dann mit 255. -weg = os.environ.get("ANGRIFF_HOST_WEG", "") -if weg: - with open(os.environ["ANGRIFF_PROTOKOLL"], encoding="utf-8") as datei: - if len(datei.read().splitlines()) > int(weg): - sys.stderr.write("ssh: connect to host docker-host port 2498: Connection timed out\n") - sys.exit(255) -befehl = sys.argv[-1] -if befehl.startswith("sicherung"): - # Seit Paket E9: die Lesebefehle der Sicherung, so streng wie abendkontrolle-lesen.sh (Task 724), - # gegen die Ablage unter ANGRIFF_HOST. ANGRIFF_SICHERUNG ist oder :. - import hashlib - import re - - host = os.environ["ANGRIFF_HOST"] - stoerung, _, nur = os.environ.get("ANGRIFF_SICHERUNG", "").partition(":") - lesen = re.fullmatch(r"(sicherung-summe|sicherung) ([0-9]{4}-[0-9]{2}-[0-9]{2}-(?:alpaca|ibkr))", befehl) - bestaetigen = re.fullmatch( - r"sicherung-bestaetigt ([0-9]{4}-[0-9]{2}-[0-9]{2}-(?:alpaca|ibkr)) ([0-9a-f]{64})", befehl - ) - if lesen is None and bestaetigen is None: - sys.stderr.write( - "abendkontrolle-lesen: erwartet sicherung-summe|sicherung - " - "oder sicherung-bestaetigt - \n" - ) - sys.exit(2) - name = lesen.group(2) if lesen else bestaetigen.group(1) - gestoert = stoerung if not nur or name.endswith("-" + nur) else "" - if gestoert == "langsam": - # Seit Paket NT: laenger als das Zeitlimit, das der Fall in seiner Kopie setzt. - import time - - time.sleep(5) - archiv = os.path.join(host, name + ".tar") - summendatei = archiv + ".sha256" - gemeint = archiv if lesen and lesen.group(1) == "sicherung" else summendatei - if not os.path.isfile(gemeint): - sys.stderr.write("abendkontrolle-lesen: " + os.path.basename(gemeint) + " liegt auf dem Docker-Host nicht vor\n") - sys.exit(3) - with open(gemeint, "rb") as quelle: - inhalt = quelle.read() - if lesen and lesen.group(1) == "sicherung-summe": - if gestoert == "summe_gross": - inhalt = inhalt[:64].upper() + inhalt[64:] - if gestoert == "summe_fremd": - inhalt = inhalt.replace(name.encode("ascii"), b"2026-01-02-alpaca") - sys.stdout.buffer.write(inhalt) - sys.exit(0) - if lesen: - if gestoert == "archiv_unlesbar": - # Seit Paket NT: wie abendkontrolle-lesen.sh bei einem Archiv ohne Leserecht (Exit 1). - sys.stderr.write("abendkontrolle-lesen: " + name + ".tar liegt auf dem Docker-Host, laesst sich aber nicht lesen\n") - sys.exit(1) - if gestoert == "kaputt": - inhalt = inhalt[:-1] + bytes([inhalt[-1] ^ 1]) - if gestoert == "abbruch": - sys.stdout.buffer.write(inhalt[: len(inhalt) // 2]) - sys.stdout.buffer.flush() - sys.stderr.write("Connection reset by peer\n") - sys.exit(255) - sys.stdout.buffer.write(inhalt) - sys.stdout.buffer.flush() - if gestoert == "neu_gesichert": - # Der Host sichert denselben Tag neu, waehrend Windows noch holt. - neu = inhalt + b" neu gesichert" - with open(archiv, "wb") as ziel: - ziel.write(neu) - with open(summendatei, "wb") as ziel: - ziel.write((hashlib.sha256(neu).hexdigest() + " " + name + ".tar\n").encode("ascii")) - sys.exit(0) - summe = bestaetigen.group(2) - if inhalt.split(b"\n")[0].split(b" ")[0].decode("ascii") != summe: - sys.stderr.write("abendkontrolle-lesen: die Summe fuer " + name + " ist nicht die des Hosts, nichts bestaetigt\n") - sys.exit(4) - bestaetigt = os.path.join(host, "bestaetigt") - if gestoert == "bestaetigung_scheitert" or not os.path.isdir(bestaetigt): - sys.stderr.write("abendkontrolle-lesen: Bestaetigung fuer " + name + " nicht geschrieben\n") - sys.exit(1) - with open(os.path.join(bestaetigt, name), "wb") as ziel: - ziel.write((summe + "\n").encode("ascii")) - sys.exit(0) -art = os.environ.get("ANGRIFF_ART", "gut") -if art == "fehlt": - sys.stderr.write("abendkontrolle-lesen: fuer diesen Tag liegt noch kein Ergebnis\n") - sys.exit(3) -if art == "muell": - print("kein json") - sys.exit(0) -ziel = sys.argv[-1] -ibkr = ziel.endswith("-ibkr") -tag = ziel[:-5] if ibkr else ziel -teil = "ibkr" if ibkr and art != "falscher_teil" else "alpaca" -print(json.dumps({"tag": tag, "teil": teil, "befunde": [], "hinweise": [], "fakten": {"handelstag": True}, "exit": 0})) -''' - -FAELLE = [] - - -def fall(funktion): - FAELLE.append(funktion) - return funktion - - -def lade(): - # QUELLE zeigt im Modus --mutanten auf eine kaputte Kopie (seit Paket E9). - spec = importlib.util.spec_from_file_location("abendkontrolle_unter_angriff", QUELLE) - modul = importlib.util.module_from_spec(spec) - spec.loader.exec_module(modul) - return modul - - -def lauf(art, *argumente, modul=None): - """Ein Lauf von main() in einem Wegwerf-Vault; liefert Exit, Notiz, Ergebnisdateien und ssh-Aufrufe.""" - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - vault = wurzel / "vault" - (vault / "Inbox").mkdir(parents=True) - protokoll = wurzel / "ssh.jsonl" - attrappe = wurzel / "ssh_attrappe.py" - attrappe.write_text(ATTRAPPE_PY, encoding="utf-8") - cmd = wurzel / "ssh_attrappe.cmd" - cmd.write_text(f'@"{sys.executable}" "{attrappe}" %*\r\n', encoding="utf-8") - modul = modul or lade() - modul.SSH = str(cmd) - # Abdichtung (Plankritik DH, 2026-09-26): Der echte WSL-Weg lief sonst gegen den echten - # Projektordner, fragte das Gateway und schickte echte ntfy-Meldungen. wsl.exe und das - # Skript zeigen ins Leere, und fahre_wsl ist eine Attrappe, die jeden Aufruf samt beiden - # Pfaden mitschreibt und ablehnt -- ausser ein Fall hat seine eigene gesetzt (wsl_attrappe). - modul.WSL = str(wurzel / "kein-wsl" / "wsl.exe") - modul.WSL_SKRIPT = "/nicht-vorhanden/abendkontrolle_wsl.py" - # Abdichtung (Paket E9): Ohne ersten Sicherungstag holt kein Lauf eine Sicherung, und das - # Ziel liegt im Wegwerf-Ordner statt unter D:\Trading-Sicherung. Die Faelle der Sicherung - # laufen ueber sicherungslauf(). - modul.SICHERUNG_ERSTER_TAG = None - modul.SICHERUNG_ZIEL = wurzel / "sicherung-ziel" - os.environ.pop("ANGRIFF_HOST", None) - os.environ.pop("ANGRIFF_SICHERUNG", None) - os.environ.pop("ANGRIFF_HOST_WEG", None) - unerwartet = [] - if not getattr(modul.fahre_wsl, "attrappe", False): - - def wsl_verboten(tag, teil="alpaca"): - unerwartet.append((tag, teil, modul.WSL, modul.WSL_SKRIPT)) - raise RuntimeError("echter WSL-Weg ohne Attrappe") - - modul.fahre_wsl = wsl_verboten - os.environ["ANGRIFF_PROTOKOLL"] = str(protokoll) - os.environ["ANGRIFF_ART"] = art - code = modul.main(["--vault", str(vault), *argumente]) - if unerwartet: - raise AssertionError(f"WSL-Weg ohne Attrappe gerufen: {unerwartet}") - notizen = sorted((vault / "Inbox").glob("*.md")) - notiz = "\n".join(n.read_text(encoding="utf-8") for n in notizen) - ablage = vault / modul.ABLAGE_TEIL - ergebnisse = sorted(p.name for p in ablage.glob("*.json")) if ablage.exists() else [] - aufrufe = [] - if protokoll.exists(): - aufrufe = [json.loads(z) for z in protokoll.read_text(encoding="utf-8").splitlines() if z.strip()] - return code, notiz, ergebnisse, aufrufe - - -def wsl_attrappe(modul, gerufen): - """Ersetzt fahre_wsl im Modul; schreibt (tag, teil) mit und liefert ein Ergebnis des Teils.""" - - def fahre_wsl(tag, teil="alpaca"): - gerufen.append((tag, teil)) - return {"tag": tag, "teil": teil, "befunde": [], "hinweise": [], "fakten": {}, "exit": 0} - - fahre_wsl.attrappe = True - modul.fahre_wsl = fahre_wsl - - -@fall -def ergebnis_vom_host_landet_in_ablage_und_notiz(): - code, notiz, ergebnisse, aufrufe = lauf( - "gut", "--quelle", "dockerhost", "--tag", "2026-10-05", "--jetzt", "2026-10-05T23:30" - ) - assert code == 0, code - assert ergebnisse == ["2026-10-05-ibkr.json", "2026-10-05.json"], ergebnisse - assert "Abendkontrolle Trading Plattform (automatisch): ohne Befund" in notiz, notiz - assert "Abendkontrolle IBKR (automatisch): ohne Befund" in notiz, notiz - assert len(aufrufe) == 2, aufrufe - - -@fall -def ssh_mit_festem_schluessel_ohne_rueckfrage_nur_zum_bekannten_host(): - _, _, _, aufrufe = lauf("gut", "--quelle", "dockerhost", "--tag", "2026-10-05", "--jetzt", "2026-10-05T23:30") - for argumente, ziel in zip(aufrufe, ("2026-10-05", "2026-10-05-ibkr"), strict=True): - assert argumente[:3] == ["-p", "2498", "-i"], argumente - assert argumente[3].endswith("trading-dh-kontrolle"), argumente - assert argumente[4:14] == [ - "-o", "BatchMode=yes", - "-o", "IdentitiesOnly=yes", - "-o", "StrictHostKeyChecking=yes", - "-o", "UserKnownHostsFile=~/.ssh/known_hosts_trading_dh", - "-o", "ConnectTimeout=20", - ], argumente - assert argumente[14:] == ["kontrolle@192.168.250.10", ziel], argumente - - -@fall -def noch_kein_ergebnis_ist_ein_befund_in_der_notiz_und_wird_wiederholt(): - code, notiz, ergebnisse, _ = lauf( - "fehlt", "--quelle", "dockerhost", "--tag", "2026-10-05", "--jetzt", "2026-10-05T23:30" - ) - assert code == 1, code - assert ergebnisse == [], ergebnisse - assert "**gescheitert**" in notiz and "noch kein Ergebnis" in notiz, notiz - assert "#needs-review" in notiz, notiz - assert "fuer 2026-10-05-ibkr noch kein Ergebnis" in notiz, notiz - - -@fall -def muell_vom_host_ist_ein_befund(): - code, notiz, ergebnisse, _ = lauf( - "muell", "--quelle", "dockerhost", "--tag", "2026-10-05", "--jetzt", "2026-10-05T23:30" - ) - assert code == 1, code - assert ergebnisse == [], ergebnisse - assert "keine JSON-Zeile vom Docker-Host" in notiz, notiz - - -@fall -def faellige_tage_kommen_einzeln_ueber_ssh(): - modul = lade() - _, _, ergebnisse, aufrufe = lauf("gut", "--quelle", "dockerhost", "--jetzt", "2026-10-05T22:10", modul=modul) - tage = ["2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01", "2026-10-02"] - ibkr = [f"{t}-ibkr" for t in tage if t >= modul.IBKR_ERSTER_TAG.isoformat()] - ziele = [a[-1] for a in aufrufe] - assert ziele == tage + ibkr, ziele - assert ergebnisse == sorted(f"{z}.json" for z in ziele), ergebnisse - - -@fall -def ohne_schalter_bleibt_es_bei_wsl(): - modul = lade() - gerufen = [] - wsl_attrappe(modul, gerufen) - - def verboten(*_argumente): - raise AssertionError("ohne --quelle darf nichts ueber SSH laufen") - - modul.fahre_dockerhost = verboten - code, _, ergebnisse, aufrufe = lauf("gut", "--tag", "2026-10-05", "--jetzt", "2026-10-05T23:30", modul=modul) - assert code == 0, code - assert gerufen == [("2026-10-05", "alpaca"), ("2026-10-05", "ibkr")], gerufen - assert aufrufe == [], aufrufe - assert ergebnisse == ["2026-10-05-ibkr.json", "2026-10-05.json"], ergebnisse - - -@fall -def die_ibkr_instanz_bleibt_mit_quelle_ibkr_wsl_in_wsl(): - modul = lade() - gerufen = [] - wsl_attrappe(modul, gerufen) - code, _, ergebnisse, aufrufe = lauf( - "gut", "--quelle", "dockerhost", "--quelle-ibkr", "wsl", "--tag", "2026-10-05", "--jetzt", "2026-10-05T23:30", - modul=modul, - ) - assert code == 0, code - assert [a[-1] for a in aufrufe] == ["2026-10-05"], aufrufe - assert gerufen == [("2026-10-05", "ibkr")], gerufen - assert ergebnisse == ["2026-10-05-ibkr.json", "2026-10-05.json"], ergebnisse - - -@fall -def ein_ergebnis_des_falschen_teils_vom_host_gilt_als_gescheitert(): - code, notiz, ergebnisse, aufrufe = lauf( - "falscher_teil", "--quelle", "dockerhost", "--teil", "ibkr", "--tag", "2026-10-05", "--jetzt", "2026-10-05T23:30" - ) - assert code == 1, code - assert [a[-1] for a in aufrufe] == ["2026-10-05-ibkr"], aufrufe - assert ergebnisse == [], ergebnisse - assert "gehoert zu 'alpaca'" in notiz and "**gescheitert**" in notiz, notiz - - -@fall -def eine_unbekannte_quelle_ist_ein_bedienfehler(): - fehler = io.StringIO() - try: - with contextlib.redirect_stderr(fehler): - lauf("gut", "--quelle", "irgendwo", "--tag", "2026-10-05") - except SystemExit as ende: - assert ende.code == 2, ende.code - assert "--quelle: invalid choice" in fehler.getvalue(), fehler.getvalue() - return - raise AssertionError("argparse hat die Quelle irgendwo angenommen") - - -@fall -def die_heutige_frist_und_die_aufgabe_passen_zueinander(): - # Seit Paket E9 ueber QUELLE und AUFGABE, damit --mutanten auch diesen Fall trifft. Die Uhrzeit der - # Aufgabe gilt nur in Ortszeit, wenn das Skript StartBoundary ohne Versatz setzt (S10; die Form - # prueft der_abendausloeser_steht_in_ortszeit_ohne_versatz). - quelle = QUELLE.read_text(encoding="utf-8") - aufgabe = AUFGABE.read_text(encoding="utf-8") - assert "$Abends.StartBoundary = " in aufgabe, "Abend-Ausloeser ohne StartBoundary in Ortszeit" - assert "StrictHostKeyChecking=no" not in quelle - assert "if tag == jetzt.date() and jetzt.time() < FRUEHESTENS:" in quelle - assert aufgabe.count('[ValidateSet("wsl", "dockerhost")]') == 2 - assert "'\" --quelle ' + $Quelle + ' --quelle-ibkr ' + $QuelleIbkr" in aufgabe - assert 'if ($Quelle -eq "dockerhost" -or $QuelleIbkr -eq "dockerhost") { $Uhrzeit = "23:15" }' in aufgabe - assert "if (-not $QuelleIbkr) { $QuelleIbkr = $Quelle }" in aufgabe - - -@fall -def der_echte_wsl_weg_ist_versperrt(): - # Ohne --quelle und ohne eigene Attrappe nimmt main den WSL-Weg. Er muss als Abweichung - # enden, nicht als Lauf. - try: - lauf("gut", "--tag", "2026-10-05", "--jetzt", "2026-10-05T23:30") - except AssertionError as ausnahme: - text = str(ausnahme) - assert "WSL-Weg ohne Attrappe" in text, text - # Beleg fuer die Zeilen in lauf(): Beim Aufruf zeigten wsl.exe und das Skript ins Leere. - assert "kein-wsl" in text and "/nicht-vorhanden/" in text, text - return - raise AssertionError("ein Lauf ohne WSL-Attrappe kam durch") - - -@fall -def der_echte_wsl_aufruf_findet_kein_wsl(): - # Die zweite Sperre: Selbst die echte fahre_wsl findet in der Abdichtung kein wsl.exe, und das - # Skript, das sie dort starten wuerde, gibt es nicht. - modul = lade() - with tempfile.TemporaryDirectory() as ordner: - modul.WSL = str(Path(ordner) / "kein-wsl" / "wsl.exe") - modul.WSL_SKRIPT = "/nicht-vorhanden/abendkontrolle_wsl.py" - try: - modul.fahre_wsl("2026-10-05", "alpaca") - except FileNotFoundError: - return - raise AssertionError("fahre_wsl lief ohne wsl.exe weiter") - - -# --- Seit Paket E9: das Abholen der Sicherung ------------------------------------------------- - -TAG = "2026-10-05" -KOPF = "Sicherung Trading Plattform (automatisch)" -# Bytes, die kein Text sind, auch CR, LF und Strg-Z: Die Uebertragung muss sie unveraendert lassen. -ARCHIVE = { - "alpaca": b"tar-attrappe alpaca\r\n" + bytes(range(256)), - "ibkr": b"tar-attrappe ibkr\n" + bytes(reversed(range(256))), -} - - -def host_ablage(wurzel, tage=(TAG,), instanzen=("alpaca", "ibkr")): - """Die Ablage des Hosts wie nach sicherung-host.sh (Task 723): Archiv, Summendatei, bestaetigt/.""" - host = wurzel / "host" - (host / "bestaetigt").mkdir(parents=True) - for tag in tage: - for instanz in instanzen: - inhalt = ARCHIVE[instanz] + tag.encode("ascii") - (host / f"{tag}-{instanz}.tar").write_bytes(inhalt) - zeile = f"{hashlib.sha256(inhalt).hexdigest()} {tag}-{instanz}.tar\n" - (host / f"{tag}-{instanz}.tar.sha256").write_bytes(zeile.encode("ascii")) - return host - - -def host_summe(host, instanz, tag=TAG): - """Die Summe, die der Host fuer - fuehrt.""" - return (host / f"{tag}-{instanz}.tar.sha256").read_bytes()[:64].decode("ascii") - - -def sicherungszeilen(notiz): - """Die Zeilen der Notizen, die zur Sicherung gehoeren.""" - return [zeile for zeile in notiz.splitlines() if KOPF in zeile] - - -def einzige_zeile(notiz): - """Genau eine Zeile zur Sicherung, sonst eine Abweichung.""" - zeilen = sicherungszeilen(notiz) - assert len(zeilen) == 1, notiz - return zeilen[0] - - -def namen(ordner): - return sorted(p.name for p in ordner.iterdir()) - - -def sicherungslauf(wurzel, *argumente, modul=None, stoerung="", erster_tag=date(2026, 10, 5), schalter=True, host_weg=""): - """Ein Lauf von main() mit Quelle dockerhost und Sicherung, alles unter wurzel. - - Vault, Host-Ablage (host_ablage) und Ziel bleiben stehen, damit ein zweiter Lauf daran - weiterarbeiten kann. Abgedichtet wie lauf(): ssh ist die Attrappe, WSL zeigt ins Leere, und - fahre_wsl lehnt ohne eigene Attrappe des Falls ab. Mit schalter geht die Sicherung ueber - --sicherung-ziel nach wurzel/ziel, und das Ziel der Konstante (wurzel/konstante-ziel) muss leer - bleiben; ohne schalter gilt die Konstante. Liefert Exit, Notizen, die Befehle der Sicherung an - den Host und ihre ganzen Befehlszeilen. - """ - vault = wurzel / "vault" - (vault / "Inbox").mkdir(parents=True, exist_ok=True) - protokoll = wurzel / "ssh.jsonl" - protokoll.unlink(missing_ok=True) - attrappe = wurzel / "ssh_attrappe.py" - attrappe.write_text(ATTRAPPE_PY, encoding="utf-8") - cmd = wurzel / "ssh_attrappe.cmd" - cmd.write_text(f'@"{sys.executable}" "{attrappe}" %*\r\n', encoding="utf-8") - modul = modul or lade() - modul.SSH = str(cmd) - modul.WSL = str(wurzel / "kein-wsl" / "wsl.exe") - modul.WSL_SKRIPT = "/nicht-vorhanden/abendkontrolle_wsl.py" - unerwartet = [] - if not getattr(modul.fahre_wsl, "attrappe", False): - - def wsl_verboten(tag, teil="alpaca"): - unerwartet.append((tag, teil)) - raise RuntimeError("echter WSL-Weg ohne Attrappe") - - modul.fahre_wsl = wsl_verboten - modul.SICHERUNG_ERSTER_TAG = erster_tag - modul.SICHERUNG_ZIEL = wurzel / "konstante-ziel" - os.environ["ANGRIFF_PROTOKOLL"] = str(protokoll) - os.environ["ANGRIFF_ART"] = "gut" - os.environ["ANGRIFF_HOST"] = str(wurzel / "host") - os.environ["ANGRIFF_SICHERUNG"] = stoerung - # Seit Paket NT: "" heisst, der Host bleibt erreichbar. - os.environ["ANGRIFF_HOST_WEG"] = host_weg - vorne = ["--vault", str(vault), "--quelle", "dockerhost"] - if schalter: - vorne += ["--sicherung-ziel", str(wurzel / "ziel")] - code = modul.main([*vorne, *argumente]) - if unerwartet: - raise AssertionError(f"WSL-Weg ohne Attrappe gerufen: {unerwartet}") - if schalter and (wurzel / "konstante-ziel").exists(): - raise AssertionError("Sicherung unter SICHERUNG_ZIEL statt unter --sicherung-ziel") - notiz = "\n".join(n.read_text(encoding="utf-8") for n in sorted((vault / "Inbox").glob("*.md"))) - zeilen = protokoll.read_text(encoding="utf-8").splitlines() if protokoll.exists() else [] - ganz = [json.loads(z) for z in zeilen if z.strip()] - ganz = [argumente_ssh for argumente_ssh in ganz if argumente_ssh[-1].startswith("sicherung")] - return code, notiz, [argumente_ssh[-1] for argumente_ssh in ganz], ganz - - -@fall -def sicherung_wird_geholt_geprueft_abgelegt_und_bestaetigt(): - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host = host_ablage(wurzel) - code, notiz, befehle, ganz = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30") - assert code == 0, (code, notiz) - erwartet = [] - for instanz in ("alpaca", "ibkr"): - erwartet += [ - f"sicherung-summe {TAG}-{instanz}", - f"sicherung {TAG}-{instanz}", - f"sicherung-bestaetigt {TAG}-{instanz} {host_summe(host, instanz)}", - ] - assert befehle == erwartet, befehle - for argumente_ssh in ganz: - assert argumente_ssh[:3] == ["-p", "2498", "-i"], argumente_ssh - assert argumente_ssh[3].endswith("trading-dh-kontrolle"), argumente_ssh - assert argumente_ssh[4:15] == [ - "-o", "BatchMode=yes", - "-o", "IdentitiesOnly=yes", - "-o", "StrictHostKeyChecking=yes", - "-o", "UserKnownHostsFile=~/.ssh/known_hosts_trading_dh", - "-o", "ConnectTimeout=20", - "kontrolle@192.168.250.10", - ], argumente_ssh - assert len(argumente_ssh) == 16, argumente_ssh - ziel = wurzel / "ziel" / TAG - assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar", "ibkr.tar.sha256"], namen(ziel) - groessen = {} - for instanz in ("alpaca", "ibkr"): - inhalt = (host / f"{TAG}-{instanz}.tar").read_bytes() - summe = hashlib.sha256(inhalt).hexdigest() - assert (ziel / f"{instanz}.tar").read_bytes() == inhalt, instanz - assert (ziel / f"{instanz}.tar.sha256").read_bytes() == f"{summe} {instanz}.tar\n".encode("ascii"), instanz - assert (host / "bestaetigt" / f"{TAG}-{instanz}").read_bytes() == f"{summe}\n".encode("ascii"), instanz - groessen[instanz] = len(inhalt) - assert einzige_zeile(notiz) == ( - f"- 23:30 Uhr — {KOPF}: alpaca abgelegt und bestätigt ({groessen['alpaca']} Bytes); " - f"ibkr abgelegt und bestätigt ({groessen['ibkr']} Bytes)." - ), notiz - assert notiz.index("## Agentenprotokoll") < notiz.index(KOPF), notiz - - -@fall -def eine_falsche_summe_wird_nie_abgelegt_und_nie_bestaetigt(): - # Reihenfolge: derselbe Schaden an der ersten und an der letzten Instanz. - for kaputt, heil in (("alpaca", "ibkr"), ("ibkr", "alpaca")): - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host = host_ablage(wurzel) - code, notiz, befehle, _ = sicherungslauf( - wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", stoerung=f"kaputt:{kaputt}" - ) - zeile = einzige_zeile(notiz) - assert code == 1, code - assert namen(wurzel / "ziel" / TAG) == [f"{heil}.tar", f"{heil}.tar.sha256"], namen(wurzel / "ziel" / TAG) - assert namen(host / "bestaetigt") == [f"{TAG}-{heil}"], namen(host / "bestaetigt") - assert [b for b in befehle if b.startswith("sicherung-bestaetigt ")] == [ - f"sicherung-bestaetigt {TAG}-{heil} {host_summe(host, heil)}" - ], befehle - assert "**gescheitert** #needs-review" in zeile, zeile - assert f"{kaputt} gescheitert (RuntimeError: Summe des geholten Archivs ist nicht die des Hosts" in zeile, zeile - assert f"{heil} abgelegt und bestätigt" in zeile, zeile - - -@fall -def eine_abgebrochene_uebertragung_hinterlaesst_nichts(): - # Seit Paket NT (Plankritik, Befund 1): Exit 255 mitten im Archiv heisst nicht, dass der Host fehlt; - # er hat die Summe gerade beantwortet. Nur die kaputte Instanz scheitert, mit dem Befehl im Grund, - # die andere wird geholt. Keine Nachbardatei bleibt, die kaputte Instanz ist nicht bestaetigt. - for kaputt, heil in (("alpaca", "ibkr"), ("ibkr", "alpaca")): - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host = host_ablage(wurzel) - code, notiz, befehle, _ = sicherungslauf( - wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", stoerung=f"abbruch:{kaputt}" - ) - zeile = einzige_zeile(notiz) - assert code == 1, code - assert namen(wurzel / "ziel" / TAG) == [f"{heil}.tar", f"{heil}.tar.sha256"], namen(wurzel / "ziel" / TAG) - assert namen(host / "bestaetigt") == [f"{TAG}-{heil}"], namen(host / "bestaetigt") - assert not any(b.startswith(f"sicherung-bestaetigt {TAG}-{kaputt} ") for b in befehle), befehle - assert ( - f"{kaputt} gescheitert (RuntimeError: Archiv nicht geholt (sicherung {TAG}-{kaputt}: Exit 255; " - "Connection reset by peer))" - ) in zeile, zeile - assert f"{heil} abgelegt und bestätigt" in zeile, zeile - assert "**gescheitert** #needs-review" in zeile, zeile - assert "Docker-Host nicht erreichbar" not in zeile, zeile - - -@fall -def exit_3_heisst_noch_keine_sicherung_auf_dem_host(): - for vorhanden in ((), ("ibkr",), ("alpaca",)): - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host = host_ablage(wurzel, instanzen=vorhanden) - # Seit der Plankritik Runde 1 ist Exit 3 nur fuer einen vergangenen Werktag ein Fehlschlag: - # TAG ist ein Montag, der Lauf kommt am Dienstag. - code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-06T23:30") - zeile = einzige_zeile(notiz) - assert code == 1, (vorhanden, code) - assert "**gescheitert** #needs-review" in zeile, zeile - for instanz in ("alpaca", "ibkr"): - if instanz in vorhanden: - assert f"{instanz} abgelegt und bestätigt" in zeile, zeile - else: - assert f"auf dem Docker-Host liegt fuer {TAG}-{instanz} noch keine Sicherung" in zeile, zeile - assert f"sicherung {TAG}-{instanz}" not in befehle, befehle - assert not (wurzel / "ziel" / TAG / f"{instanz}.tar").exists(), instanz - assert namen(host / "bestaetigt") == [f"{TAG}-{i}" for i in vorhanden], namen(host / "bestaetigt") - - -@fall -def exit_3_fuer_heute_und_das_wochenende_ist_still(): - # Plankritik Runde 1, Befunde 8, 16 und 18: Heute kann die Sicherung des Hosts noch ausstehen, und - # am Wochenende sichert er nur, wenn er einen Lauf nachholt. Exit 3 ist dann kein Fehlschlag und - # gibt fuer sich allein keine Zeile. - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host_ablage(wurzel, tage=()) - code, notiz, befehle, _ = sicherungslauf( - wurzel, "--jetzt", "2026-10-05T18:00", erster_tag=date(2026, 10, 3) - ) - assert code == 0, (code, notiz) - assert befehle == [ - f"sicherung-summe {t}-{i}" for t in ("2026-10-03", "2026-10-04", "2026-10-05") for i in ("alpaca", "ibkr") - ], befehle - assert sicherungszeilen(notiz) == [], notiz - assert not (wurzel / "ziel").exists() - code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30") - assert code == 0, (code, notiz) - assert befehle == [f"sicherung-summe {TAG}-alpaca", f"sicherung-summe {TAG}-ibkr"], befehle - assert sicherungszeilen(notiz) == [], notiz - # Fehlt heute nur eine Instanz, nennt die Zeile sie ohne #needs-review. - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host_ablage(wurzel, instanzen=("alpaca",)) - code, notiz, _, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30") - zeile = einzige_zeile(notiz) - assert code == 0, (code, notiz) - assert zeile.endswith("ibkr auf dem Docker-Host noch nicht gesichert."), zeile - assert "#needs-review" not in zeile, zeile - - -@fall -def eine_vollstaendige_sicherung_auf_d_wird_nicht_nochmal_geholt(): - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host_ablage(wurzel) - erster = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30") - assert erster[0] == 0, erster[:2] - ziel = wurzel / "ziel" / TAG - vorher = {p.name: p.read_bytes() for p in ziel.iterdir()} - code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:45") - assert code == 0, code - # Seit der Plankritik Runde 1 fragt jeder Lauf die Summe des Hosts; geholt wird nur bei Abweichung. - assert befehle == [f"sicherung-summe {TAG}-alpaca", f"sicherung-summe {TAG}-ibkr"], befehle - assert {p.name: p.read_bytes() for p in ziel.iterdir()} == vorher - assert len(sicherungszeilen(notiz)) == 1, notiz - - -@fall -def ein_neu_gesicherter_tag_wird_neu_geholt_und_ersetzt(): - # Plankritik Runde 1, Befunde 5 und 24: Sichert der Host einen Tag nach dem Abholen ein zweites Mal - # (Abnahme 3 von Hand, um 16:45 der Zeitgeber), entfernt er dessen Bestaetigung (Task 723). Der - # naechste Lauf sieht die neue Summe, holt neu, ersetzt die alte Fassung auf D: und bestaetigt. - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host = host_ablage(wurzel) - erster = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T10:30") - assert erster[0] == 0, erster[:2] - neu = ARCHIVE["alpaca"] + b" zweiter Lauf" - summe = hashlib.sha256(neu).hexdigest() - (host / f"{TAG}-alpaca.tar").write_bytes(neu) - (host / f"{TAG}-alpaca.tar.sha256").write_bytes(f"{summe} {TAG}-alpaca.tar\n".encode("ascii")) - (host / "bestaetigt" / f"{TAG}-alpaca").unlink() - code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:15") - ziel = wurzel / "ziel" / TAG - assert code == 0, (code, notiz) - assert befehle == [ - f"sicherung-summe {TAG}-alpaca", - f"sicherung {TAG}-alpaca", - f"sicherung-bestaetigt {TAG}-alpaca {summe}", - f"sicherung-summe {TAG}-ibkr", - ], befehle - assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar", "ibkr.tar.sha256"], namen(ziel) - assert (ziel / "alpaca.tar").read_bytes() == neu - assert (ziel / "alpaca.tar.sha256").read_bytes() == f"{summe} alpaca.tar\n".encode("ascii") - assert (host / "bestaetigt" / f"{TAG}-alpaca").read_bytes() == f"{summe}\n".encode("ascii") - zeilen = sicherungszeilen(notiz) - assert len(zeilen) == 2, notiz - assert "alpaca abgelegt und bestätigt" in zeilen[1] and "ibkr lag schon vollständig" in zeilen[1], zeilen[1] - - -@fall -def nur_die_fehlende_instanz_wird_geholt(): - for da, fehlt in (("alpaca", "ibkr"), ("ibkr", "alpaca")): - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host = host_ablage(wurzel) - ziel = wurzel / "ziel" / TAG - ziel.mkdir(parents=True) - (ziel / f"{da}.tar").write_bytes((host / f"{TAG}-{da}.tar").read_bytes()) - (ziel / f"{da}.tar.sha256").write_bytes(f"{host_summe(host, da)} {da}.tar\n".encode("ascii")) - code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30") - zeile = einzige_zeile(notiz) - assert code == 0, (code, notiz) - summen = [b.split(" ")[1] for b in befehle if b.startswith("sicherung-summe ")] - assert summen == [f"{TAG}-alpaca", f"{TAG}-ibkr"], befehle - assert [b.split(" ")[1] for b in befehle if not b.startswith("sicherung-summe ")] == [f"{TAG}-{fehlt}"] * 2, befehle - assert f"{da} lag schon vollständig" in zeile and f"{fehlt} abgelegt und bestätigt" in zeile, zeile - assert namen(host / "bestaetigt") == [f"{TAG}-{fehlt}"], namen(host / "bestaetigt") - - -@fall -def fremder_inhalt_im_zielordner_bleibt_und_zaehlt_nicht_als_sicherung(): - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host = host_ablage(wurzel) - ziel = wurzel / "ziel" / TAG - ziel.mkdir(parents=True) - (ziel / "notiz.txt").write_bytes(b"fremd\n") - (ziel / "alpaca.tar.teil").write_bytes(b"Rest eines abgebrochenen Laufs") - # alpaca: die passende Summendatei, aber kein Archiv daneben. ibkr: richtiges Archiv und richtige - # Summe, aber in der Summendatei der Name einer anderen Datei. Das Archiv selbst hasht der Lauf - # nicht nach (Plankritik Runde 1, Befund 21); beides zaehlt trotzdem nicht als Sicherung. - (ziel / "alpaca.tar.sha256").write_bytes(f"{host_summe(host, 'alpaca')} alpaca.tar\n".encode("ascii")) - (ziel / "ibkr.tar").write_bytes((host / f"{TAG}-ibkr.tar").read_bytes()) - (ziel / "ibkr.tar.sha256").write_bytes(f"{host_summe(host, 'ibkr')} alpaca.tar\n".encode("ascii")) - code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30") - assert code == 0, (code, notiz) - assert len([b for b in befehle if b.startswith("sicherung-bestaetigt ")]) == 2, befehle - assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar", "ibkr.tar.sha256", "notiz.txt"], namen(ziel) - assert (ziel / "notiz.txt").read_bytes() == b"fremd\n" - for instanz in ("alpaca", "ibkr"): - summe = host_summe(host, instanz) - assert (ziel / f"{instanz}.tar").read_bytes() == (host / f"{TAG}-{instanz}.tar").read_bytes(), instanz - assert (ziel / f"{instanz}.tar.sha256").read_bytes() == f"{summe} {instanz}.tar\n".encode("ascii"), instanz - - -@fall -def ein_tagesordner_der_eine_datei_ist_ist_ein_befund(): - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host = host_ablage(wurzel) - (wurzel / "ziel").mkdir() - (wurzel / "ziel" / TAG).write_bytes(b"keine Sicherung\n") - code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30") - zeile = einzige_zeile(notiz) - assert code == 1, code - assert befehle == [f"sicherung-summe {TAG}-alpaca", f"sicherung-summe {TAG}-ibkr"], befehle - assert (wurzel / "ziel" / TAG).read_bytes() == b"keine Sicherung\n" - assert namen(host / "bestaetigt") == [], namen(host / "bestaetigt") - assert "**gescheitert** #needs-review" in zeile and zeile.count("FileExistsError") == 2, zeile - - -@fall -def eine_gescheiterte_bestaetigung_laesst_die_summendatei_weg_und_wird_nachgeholt(): - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host = host_ablage(wurzel) - ziel = wurzel / "ziel" / TAG - ziel.mkdir(parents=True) - # Eine aeltere, in sich stimmige Fassung: Der Host hat den Tag seitdem neu gesichert. Ihre - # Summendatei muss vor dem Umbenennen weg, sonst laege neben dem neuen Archiv eine Summe, die - # nicht passt, und sha256sum -c schluege bei der Ruecksicherung fehl. - alt = b"aeltere Fassung" - (ziel / "alpaca.tar").write_bytes(alt) - (ziel / "alpaca.tar.sha256").write_bytes(f"{hashlib.sha256(alt).hexdigest()} alpaca.tar\n".encode("ascii")) - code, notiz, _, _ = sicherungslauf( - wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", stoerung="bestaetigung_scheitert" - ) - zeile = einzige_zeile(notiz) - assert code == 1, code - assert namen(ziel) == ["alpaca.tar", "ibkr.tar"], namen(ziel) - assert namen(host / "bestaetigt") == [], namen(host / "bestaetigt") - assert "#needs-review" in zeile and zeile.count("abgelegt, aber nicht bestaetigt (Exit 1;") == 2, zeile - code, notiz, befehle, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:45") - assert code == 0, (code, notiz) - assert len([b for b in befehle if b.startswith("sicherung-bestaetigt ")]) == 2, befehle - assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar", "ibkr.tar.sha256"], namen(ziel) - assert namen(host / "bestaetigt") == [f"{TAG}-alpaca", f"{TAG}-ibkr"], namen(host / "bestaetigt") - - -@fall -def eine_vom_host_abgelehnte_summe_ist_ein_befund_und_wird_neu_geholt(): - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host = host_ablage(wurzel) - alt = (host / f"{TAG}-ibkr.tar").read_bytes() - code, notiz, _, _ = sicherungslauf( - wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", stoerung="neu_gesichert:ibkr" - ) - ziel = wurzel / "ziel" / TAG - zeile = einzige_zeile(notiz) - assert code == 1, code - assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar"], namen(ziel) - assert (ziel / "ibkr.tar").read_bytes() == alt - assert namen(host / "bestaetigt") == [f"{TAG}-alpaca"], namen(host / "bestaetigt") - assert "ibkr gescheitert (RuntimeError: abgelegt, aber nicht bestaetigt (Exit 4;" in zeile, zeile - assert "#needs-review" in zeile, zeile - code, _, _, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:45") - neu = (host / f"{TAG}-ibkr.tar").read_bytes() - assert code == 0, code - assert neu != alt and (ziel / "ibkr.tar").read_bytes() == neu - assert (host / "bestaetigt" / f"{TAG}-ibkr").read_bytes() == f"{host_summe(host, 'ibkr')}\n".encode("ascii") - - -@fall -def eine_summendatei_des_hosts_in_falscher_form_holt_kein_archiv(): - for stoerung in ("summe_gross", "summe_fremd"): - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host = host_ablage(wurzel) - code, notiz, befehle, _ = sicherungslauf( - wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", stoerung=stoerung - ) - zeile = einzige_zeile(notiz) - assert code == 1, (stoerung, code) - assert befehle == [f"sicherung-summe {TAG}-alpaca", f"sicherung-summe {TAG}-ibkr"], (stoerung, befehle) - assert not (wurzel / "ziel" / TAG).exists(), stoerung - assert namen(host / "bestaetigt") == [], (stoerung, namen(host / "bestaetigt")) - assert "#needs-review" in zeile and zeile.count("Summendatei des Hosts in unerwarteter Form") == 2, zeile - - -@fall -def ohne_ersten_sicherungstag_holt_kein_lauf_eine_sicherung(): - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host_ablage(wurzel) - code, notiz, befehle, _ = sicherungslauf( - wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", erster_tag=None - ) - assert code == 0, code - assert befehle == [], befehle - assert not (wurzel / "ziel").exists() - assert sicherungszeilen(notiz) == [], notiz - - -@fall -def faellige_tage_der_sicherung_sind_alle_kalendertage_des_fensters(): - # Plankritik Runde 1, Befunde 16, 18 und 19: Gefragt wird jeder Kalendertag der letzten - # SICHERUNG_NACHHOLEN_TAGE bis heute, der aelteste zuerst; auch Samstag und Sonntag (ein - # nachgeholter Lauf des Hosts traegt das Datum des Nachholens) und heute schon vor 23 Uhr. - auf_dem_host = [ - "2026-10-01", "2026-10-02", "2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08", "2026-10-09", - "2026-10-10", "2026-10-12", - ] - fenster = [f"2026-10-{t:02d}" for t in range(2, 13)] - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host_ablage(wurzel, tage=auf_dem_host) - modul = lade() - # Die Ergebnisse der Abendkontrolle liegen schon alle; die Sicherung ist trotzdem faellig. - ablage = wurzel / "vault" / modul.ABLAGE_TEIL - ablage.mkdir(parents=True) - for tag in fenster: - (ablage / f"{tag}.json").write_text("{}", encoding="utf-8") - (ablage / f"{tag}-ibkr.json").write_text("{}", encoding="utf-8") - code, notiz, befehle, _ = sicherungslauf( - wurzel, "--jetzt", "2026-10-12T10:00", modul=modul, erster_tag=date(2026, 9, 1) - ) - summen = [b.split(" ")[1] for b in befehle if b.startswith("sicherung-summe ")] - geholt = [b.split(" ")[1] for b in befehle if b.startswith("sicherung ")] - assert code == 0, (code, notiz) - assert summen == [f"{t}-{i}" for t in fenster for i in ("alpaca", "ibkr")], summen - # 2026-10-01 liegt vor dem Fenster; 10-03, 10-04 und 10-11 fehlen auf dem Host (Wochenende, still). - assert geholt == [f"{t}-{i}" for t in auf_dem_host[1:] for i in ("alpaca", "ibkr")], geholt - # Der erste Sicherungstag begrenzt das Fenster von unten; schon Geholtes wird nur nachgefragt. - code, _, befehle, _ = sicherungslauf(wurzel, "--jetzt", "2026-10-12T10:30", erster_tag=date(2026, 10, 6)) - summen = [b.split(" ")[1] for b in befehle if b.startswith("sicherung-summe ")] - assert code == 0, code - assert summen == [f"2026-10-{t:02d}-{i}" for t in range(6, 13) for i in ("alpaca", "ibkr")], summen - assert [b for b in befehle if not b.startswith("sicherung-summe ")] == [], befehle - - -@fall -def das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit(): - # Plankritik Runde 1, Befund 12: Jedes Archiv hat SICHERUNG_TIMEOUT_S (600 s), der ganze Lauf - # SICHERUNG_BUDGET_S (1500 s). Runde 2, Befunde 1 und 4: Das Budget zaehlt ab dem Start des Laufs, - # die Ergebnisse eingerechnet, und ist bei genau 1500 s um. Die Uhr ist eine Attrappe: Jeder - # Ergebnisteil stellt sie 400 s vor, jede Uebertragung 700 s; vor ibkr steht sie auf 1500. - tage = ["2026-10-01", "2026-10-02", "2026-10-05"] - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host_ablage(wurzel, tage=tage) - modul = lade() - echt = modul.ssh_lauf - echt_teil = modul.fahre_teil - uhr = [0.0] - zeitlimits = [] - - def ssh_lauf(befehl, timeout, stdout=None): - if befehl.startswith("sicherung "): - zeitlimits.append(timeout) - uhr[0] += 700 - return echt(befehl, timeout, stdout) - - def fahre_teil(*argumente, **benannt): - uhr[0] += 400 - return echt_teil(*argumente, **benannt) - - modul.ssh_lauf = ssh_lauf - modul.fahre_teil = fahre_teil - modul.monotonic = lambda: uhr[0] - code, notiz, befehle, _ = sicherungslauf( - wurzel, "--jetzt", "2026-10-05T23:30", modul=modul, erster_tag=date(2026, 10, 1) - ) - assert code == 0, (code, notiz) - assert uhr[0] == 1500, uhr - assert zeitlimits == [600], zeitlimits - assert [b.split(" ")[1] for b in befehle if b.startswith("sicherung ")] == ["2026-10-01-alpaca"], befehle - # Nach dem Budget fragt der Lauf auch keine Summe mehr. - assert [b.split(" ")[1] for b in befehle if b.startswith("sicherung-summe ")] == [ - "2026-10-01-alpaca" - ], befehle - zeilen = sicherungszeilen(notiz) - assert len(zeilen) == 1, notiz - assert zeilen[0].endswith( - "ibkr nicht angefangen: Zeitbudget des Laufs (1500 s) erschöpft, der nächste Lauf holt den Rest." - ), zeilen[0] - assert "#needs-review" not in "\n".join(zeilen), zeilen - # Der naechste Lauf holt den Rest; die schon geholten Tage fragt er nur nach der Summe. Seine - # Ergebnisse liegen schon, sein Ergebnisteil kostet hier keine Zeit. - modul.fahre_teil = echt_teil - uhr[0] = 0.0 - zeitlimits.clear() - code, _, befehle, _ = sicherungslauf( - wurzel, "--jetzt", "2026-10-05T23:45", modul=modul, erster_tag=date(2026, 10, 1) - ) - assert code == 0, code - assert [b.split(" ")[1] for b in befehle if b.startswith("sicherung ")] == [ - "2026-10-01-ibkr", "2026-10-02-alpaca", "2026-10-02-ibkr" - ], befehle - - -@fall -def nur_teile_mit_quelle_dockerhost_holen_eine_sicherung(): - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host_ablage(wurzel) - modul = lade() - gerufen = [] - wsl_attrappe(modul, gerufen) - code, notiz, befehle, _ = sicherungslauf( - wurzel, "--quelle-ibkr", "wsl", "--tag", TAG, "--jetzt", "2026-10-05T23:30", modul=modul - ) - assert code == 0, (code, notiz) - assert gerufen == [(TAG, "ibkr")], gerufen - assert [b.split(" ")[1] for b in befehle] == [f"{TAG}-alpaca"] * 3, befehle - assert "ibkr" not in einzige_zeile(notiz), notiz - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host_ablage(wurzel) - code, notiz, befehle, _ = sicherungslauf(wurzel, "--teil", "ibkr", "--tag", TAG, "--jetzt", "2026-10-05T23:30") - assert code == 0, (code, notiz) - assert [b.split(" ")[1] for b in befehle] == [f"{TAG}-ibkr"] * 3, befehle - assert "alpaca" not in einzige_zeile(notiz), notiz - - -@fall -def ohne_schalter_gilt_das_ziel_der_konstante(): - assert lade().SICHERUNG_ZIEL == Path(r"D:\Trading-Sicherung"), "Ziel der Konstante" - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host_ablage(wurzel) - code, notiz, _, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", schalter=False) - assert code == 0, (code, notiz) - ziel = wurzel / "konstante-ziel" / TAG - assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar", "ibkr.tar.sha256"], namen(ziel) - assert not (wurzel / "ziel").exists() - - -@fall -def ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh(): - # Seit Paket NT (Quorum 4): main prueft --tag gleich nach dem Zerlegen. Vorher lief die - # Abendkontrolle mit dem falschen Tag los und legte Inbox/.md an, mit ../ auch ausserhalb von - # Inbox/; seit E9 hielt nur die Sicherung an (Exit 1). Jetzt endet der ganze Lauf vorher mit Exit 2. - for tag in ("2026-10-5", "2026-13-05", "2026-02-30", "20261005", "x026-10-05", "2026-10-0x", "../2026-10-05", ""): - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host_ablage(wurzel) - fehler = io.StringIO() - with contextlib.redirect_stderr(fehler): - code, notiz, _, _ = sicherungslauf(wurzel, "--tag", tag, "--jetzt", "2026-10-05T23:30") - assert code == 2, (tag, code) - assert fehler.getvalue() == f"abendkontrolle: --tag {tag!a} ist kein Datum JJJJ-MM-TT\n", (tag, fehler.getvalue()) - assert namen(wurzel / "vault") == ["Inbox"], (tag, namen(wurzel / "vault")) - assert namen(wurzel / "vault" / "Inbox") == [], (tag, notiz) - assert not (wurzel / "ssh.jsonl").exists(), tag - assert not (wurzel / "ziel").exists(), tag - - -@fall -def der_abendausloeser_steht_in_ortszeit_ohne_versatz(): - # S10 (Plankritik Runde 1, Befund 8): New-ScheduledTaskTrigger -At legt StartBoundary mit UTC-Versatz - # an, und die Aufgabe liefe im Winter um 22:15 Ortszeit. Ohne Versatz gesetzt, folgt sie der Ortszeit - # (gemessen vom Koordinator am 2026-10-11). Geprueft wird nur der Text des Skripts; nichts wird - # eingetragen. - zeilen = AUFGABE.read_text(encoding="utf-8").splitlines() - zeile = "$Abends.StartBoundary = (Get-Date -Format 'yyyy-MM-dd') + 'T' + $Uhrzeit + ':00'" - assert zeilen.count(zeile) == 1, f"StartBoundary in Ortszeit {zeilen.count(zeile)}-mal" - gesetzt = [z for z in zeilen if ".StartBoundary =" in z] - assert gesetzt == [zeile], gesetzt - ausloeser = [i for i, z in enumerate(zeilen) if z.startswith("$Abends = New-ScheduledTaskTrigger -Weekly ")] - eintragen = [i for i, z in enumerate(zeilen) if z.startswith("Register-ScheduledTask ")] - assert len(ausloeser) == 1 and len(eintragen) == 1, (ausloeser, eintragen) - assert ausloeser[0] < zeilen.index(zeile) < eintragen[0], (ausloeser, zeilen.index(zeile), eintragen) - - -# --- Seit Paket NT (Quorum 4): Host nicht erreichbar, --tag frueh, unlesbare Dateien -------------- - -HOST_WEG = "ssh: connect to host docker-host port 2498: Connection timed out" - - -def ergebnisse_liegen(modul, wurzel): - """Die Ergebnisse beider Teile liegen fuer 2026-09-20 bis 2026-10-12: Der Lauf faehrt nur die Sicherung.""" - ablage = wurzel / "vault" / modul.ABLAGE_TEIL - ablage.mkdir(parents=True, exist_ok=True) - for tag in [f"2026-09-{t}" for t in range(20, 31)] + [f"2026-10-{t:02d}" for t in range(1, 13)]: - (ablage / f"{tag}.json").write_text("{}", encoding="utf-8") - (ablage / f"{tag}-ibkr.json").write_text("{}", encoding="utf-8") - return ablage - - -@fall -def ein_unlesbares_archiv_auf_dem_host_scheitert_nur_fuer_seine_instanz(): - # Paket NT (Quorum 4, G2): abendkontrolle-lesen.sh endet bei einem Archiv ohne Leserecht mit Exit 1 - # und eigener Zeile. Das ist kein Ausfall des Hosts: nur diese Instanz scheitert, die andere wird - # geholt. Seit ssh_lauf Exit 255 als HostNichtErreichbar meldet, erreicht ein Abbruch mitten im - # Archiv die Pruefung des Exit-Codes nicht mehr; den Mutanten "Exit des Archivs egal" traegt - # deshalb dieser Fall (vorher eine_abgebrochene_uebertragung_hinterlaesst_nichts). - for kaputt, heil in (("alpaca", "ibkr"), ("ibkr", "alpaca")): - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host = host_ablage(wurzel) - code, notiz, _, _ = sicherungslauf( - wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", stoerung=f"archiv_unlesbar:{kaputt}" - ) - zeile = einzige_zeile(notiz) - assert code == 1, code - assert namen(wurzel / "ziel" / TAG) == [f"{heil}.tar", f"{heil}.tar.sha256"], namen(wurzel / "ziel" / TAG) - assert namen(host / "bestaetigt") == [f"{TAG}-{heil}"], namen(host / "bestaetigt") - assert ( - f"{kaputt} gescheitert (RuntimeError: Archiv nicht geholt (Exit 1; abendkontrolle-lesen: " - f"{TAG}-{kaputt}.tar liegt auf dem Docker-Host, laesst sich aber nicht lesen)" - ) in zeile, zeile - assert f"{heil} abgelegt und bestätigt" in zeile, zeile - assert "Docker-Host nicht erreichbar" not in zeile, zeile - - -@fall -def ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz(): - # Paket NT (Quorum 4, G1): Vorher scheiterte bei einem Host ohne Verbindung jeder Tag des Fensters - # mit eigener Zeile, und schreibe_notiz legte die fehlenden Notizen an, auch fuer Samstag und - # Sonntag. Jetzt endet die Sicherung beim ersten Exit 255 mit genau einer Zeile von heute. - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - modul = lade() - ablage = ergebnisse_liegen(modul, wurzel) - code, notiz, befehle, _ = sicherungslauf( - wurzel, "--jetzt", "2026-10-12T23:30", modul=modul, erster_tag=date(2026, 9, 1), host_weg="0" - ) - assert code == 1, (code, notiz) - assert namen(wurzel / "vault" / "Inbox") == ["2026-10-12.md"], namen(wurzel / "vault" / "Inbox") - assert einzige_zeile(notiz) == ( - f"- 23:30 Uhr — {KOPF}: **gescheitert** #needs-review. Docker-Host nicht erreichbar " - f"(sicherung-summe 2026-10-02-alpaca: Exit 255; {HOST_WEG}), Sicherung abgebrochen. " - "Der nächste Lauf versucht es erneut." - ), notiz - assert befehle == ["sicherung-summe 2026-10-02-alpaca"], befehle - assert not (wurzel / "ziel").exists() - log = (ablage / "lauf.log").read_text(encoding="utf-8") - assert "sicherung 2026-10-02-alpaca: Docker-Host nicht erreichbar (" in log, log - - -@fall -def ein_host_der_mitten_im_lauf_wegfaellt_laesst_die_zeilen_davor_stehen(): - # Paket NT (Quorum 4, G1): Fertige Tage behalten ihre Zeilen in ihren Notizen; was ein angefangener - # Tag schon hat, nennt die eine Zeile von heute. Je Tag mit Sicherung sechs Befehle (Summe, Archiv, - # Bestaetigung je Instanz); nach weg Befehlen ist der Host weg. Am Samstag 2026-10-03 liegt keine - # Sicherung, gefragt wird trotzdem. - for weg, notizen, zuletzt, bis_dahin in ( - ("12", ["2026-10-01.md", "2026-10-02.md", "2026-10-05.md"], "sicherung-summe 2026-10-03-alpaca", ""), - ("9", ["2026-10-01.md", "2026-10-05.md"], "sicherung-summe 2026-10-02-ibkr", - " Bis dahin am 2026-10-02: alpaca abgelegt und bestätigt ("), - ): - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host_ablage(wurzel, tage=("2026-10-01", "2026-10-02", "2026-10-05")) - modul = lade() - ergebnisse_liegen(modul, wurzel) - code, notiz, befehle, _ = sicherungslauf( - wurzel, "--jetzt", "2026-10-05T23:30", modul=modul, erster_tag=date(2026, 10, 1), host_weg=weg - ) - zeilen = sicherungszeilen(notiz) - assert code == 1, (weg, code) - assert namen(wurzel / "vault" / "Inbox") == notizen, (weg, namen(wurzel / "vault" / "Inbox")) - assert befehle[-1] == zuletzt and len(befehle) == int(weg) + 1, (weg, befehle) - assert namen(wurzel / "ziel") == ["2026-10-01", "2026-10-02"], (weg, namen(wurzel / "ziel")) - assert len(zeilen) == len(notizen), (weg, zeilen) - for zeile in zeilen[:-1]: - assert "alpaca abgelegt und bestätigt" in zeile and "ibkr abgelegt und bestätigt" in zeile, zeile - assert zeilen[-1].startswith( - f"- 23:30 Uhr — {KOPF}: **gescheitert** #needs-review. Docker-Host nicht erreichbar " - f"({zuletzt}: Exit 255; {HOST_WEG}), Sicherung abgebrochen." - ), zeilen[-1] - if bis_dahin: - assert bis_dahin in zeilen[-1], zeilen[-1] - else: - assert "Bis dahin" not in zeilen[-1], zeilen[-1] - assert zeilen[-1].endswith(" Der nächste Lauf versucht es erneut."), zeilen[-1] - - -@fall -def ein_zeitlimit_beim_host_zaehlt_wie_ein_unerreichbarer_host(): - # Paket NT (Quorum 4, G1): Laeuft die Frage nach der Summe in ihr Zeitlimit (SSH_TIMEOUT_S), gilt der - # Host als nicht erreichbar. In der Kopie ist das Zeitlimit 1 s, die Attrappe braucht je - # Sicherungsbefehl 5 s. Das Zeitlimit beim Archiv selbst: naechster Fall. - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host_ablage(wurzel) - modul = lade() - ergebnisse_liegen(modul, wurzel) - modul.SSH_TIMEOUT_S = 1 - code, notiz, befehle, _ = sicherungslauf(wurzel, "--jetzt", "2026-10-05T23:30", modul=modul, stoerung="langsam") - assert code == 1, (code, notiz) - assert namen(wurzel / "vault" / "Inbox") == ["2026-10-05.md"], namen(wurzel / "vault" / "Inbox") - assert befehle == ["sicherung-summe 2026-10-05-alpaca"], befehle - assert einzige_zeile(notiz) == ( - f"- 23:30 Uhr — {KOPF}: **gescheitert** #needs-review. Docker-Host nicht erreichbar " - "(sicherung-summe 2026-10-05-alpaca: Zeitlimit 1 s abgelaufen), Sicherung abgebrochen. " - "Der nächste Lauf versucht es erneut." - ), notiz - assert not (wurzel / "ziel").exists() - - -@fall -def ein_zeitlimit_beim_archiv_laesst_nur_diese_instanz_scheitern(): - # Plankritik NT, Befund 1: Laeuft nur das Archiv in sein Zeitlimit (SICHERUNG_TIMEOUT_S), ist der Host - # erreichbar; er hat die Summe gerade beantwortet. Die Instanz scheitert mit eigenem Grund, die - # andere wird geholt, der naechste Tag auch. In der Kopie ist das Zeitlimit des Archivs 1 s, die - # Attrappe braucht fuer jeden Befehl von alpaca 5 s; die Summe hat weiter SSH_TIMEOUT_S. - tage = ("2026-10-01", "2026-10-02") - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host = host_ablage(wurzel, tage=tage) - modul = lade() - ergebnisse_liegen(modul, wurzel) - modul.SICHERUNG_TIMEOUT_S = 1 - code, notiz, befehle, _ = sicherungslauf( - wurzel, "--jetzt", "2026-10-02T23:30", modul=modul, erster_tag=date(2026, 10, 1), stoerung="langsam:alpaca" - ) - zeilen = sicherungszeilen(notiz) - assert code == 1, (code, notiz) - assert namen(wurzel / "vault" / "Inbox") == ["2026-10-01.md", "2026-10-02.md"], namen(wurzel / "vault" / "Inbox") - assert namen(host / "bestaetigt") == [f"{t}-ibkr" for t in tage], namen(host / "bestaetigt") - assert [b for b in befehle if b.startswith("sicherung ")] == [ - f"sicherung {t}-{i}" for t in tage for i in ("alpaca", "ibkr") - ], befehle - assert len(zeilen) == 2, zeilen - for tag, zeile in zip(tage, zeilen): - assert namen(wurzel / "ziel" / tag) == ["ibkr.tar", "ibkr.tar.sha256"], (tag, namen(wurzel / "ziel" / tag)) - assert ( - "**gescheitert** #needs-review. alpaca gescheitert (RuntimeError: Archiv nicht geholt " - f"(sicherung {tag}-alpaca: Zeitlimit 1 s abgelaufen)); ibkr abgelegt und bestätigt (" - ) in zeile, zeile - assert "Docker-Host nicht erreichbar" not in zeile, zeile - - -@fall -def eine_bestaetigung_ohne_host_bricht_die_sicherung_ab(): - # Kritische Pruefung NT, Befund 1: Auch die Bestaetigung gehoert zur Grenze "Docker-Host nicht - # erreichbar" (Entscheidung 1 in Task 729), nicht nur die Summe. Nach Summe und Archiv von alpaca - # ist der Host weg, die Bestaetigung endet mit 255. Das Archiv liegt dann ohne Summendatei auf D:, - # nichts ist bestaetigt, und die Sicherung endet mit genau einer Zeile von heute. - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host = host_ablage(wurzel) - modul = lade() - ergebnisse_liegen(modul, wurzel) - code, notiz, befehle, _ = sicherungslauf(wurzel, "--jetzt", "2026-10-05T23:30", modul=modul, host_weg="2") - summe = host_summe(host, "alpaca") - assert code == 1, (code, notiz) - assert befehle == [ - f"sicherung-summe {TAG}-alpaca", f"sicherung {TAG}-alpaca", f"sicherung-bestaetigt {TAG}-alpaca {summe}" - ], befehle - assert einzige_zeile(notiz) == ( - f"- 23:30 Uhr — {KOPF}: **gescheitert** #needs-review. Docker-Host nicht erreichbar " - f"(sicherung-bestaetigt {TAG}-alpaca {summe}: Exit 255; {HOST_WEG}), Sicherung abgebrochen. " - "Der nächste Lauf versucht es erneut." - ), notiz - assert namen(wurzel / "ziel" / TAG) == ["alpaca.tar"], namen(wurzel / "ziel" / TAG) - assert namen(host / "bestaetigt") == [], namen(host / "bestaetigt") - - -@fall -def kein_kommentar_beginnt_mit_s3(): - # Paket NT (Quorum 4): Die Kommentarzeile ueber SICHERUNG_NACHHOLEN_TAGE begann mit "(S3).", einem - # Verweis ohne Ziel. Erst die neue Zeile zusichern, dann die alte ausschliessen. - zeilen = QUELLE.read_text(encoding="utf-8").splitlines() - neu = "# Die Sicherung entsteht 16:45 New Yorker Zeit, in Berlin meist 22:45, vom 25.10. bis 31.10.2026" - assert zeilen.count(neu) == 1, f"neue Kommentarzeile {zeilen.count(neu)}-mal" - assert [z for z in zeilen if z.startswith("# (S3)")] == [], "Kommentar beginnt mit (S3)." - - -# --- Seit Paket NR (kritische Pruefung NT, Befund 3): lauf.log nicht schreibbar --------------------- - -LOG_KOPF = "Abendkontrolle lauf.log (automatisch)" - - -def lauf_log_sperren(modul, wurzel): - """Legt an der Stelle von lauf.log einen Ordner an; liefert die Ablage und den Typ der Ausnahme. - - Oeffnen zum Anhaengen scheitert dann wie bei einer Sperre oder fehlenden Rechten, und die Ablage - daneben bleibt schreibbar. Den Typ misst der Fall hier selbst, statt ihn je Betriebssystem zu raten. - """ - ablage = wurzel / "vault" / modul.ABLAGE_TEIL - gesperrt = ablage / "lauf.log" - gesperrt.mkdir(parents=True) - try: - with gesperrt.open("a", encoding="utf-8"): - pass - except OSError as fehler: - return ablage, type(fehler).__name__ - raise AssertionError("lauf.log als Ordner liess sich zum Anhaengen oeffnen") - - -def log_zeile(uhrzeit, typ): - """Die eine Zeile, die ein Lauf mit nicht schreibbarem lauf.log in die Notiz von heute schreibt.""" - return ( - f"- {uhrzeit} Uhr — {LOG_KOPF}: **nicht schreibbar** ({typ}) #needs-review. Mindestens ein Eintrag " - "dieses Laufs fehlt dort; Ergebnisdateien, Notizzeilen und Sicherung hängen nicht davon ab." - ) - - -def log_zeilen(notiz): - """Die Zeilen der Notizen, die lauf.log nennen.""" - return [zeile for zeile in notiz.splitlines() if LOG_KOPF in zeile] - - -@fall -def ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf(): - # Kritische Pruefung NT, Befund 3: Vorher warf logge schon beim Start jedes Teils, beide Teile fielen - # aus, die Sicherung brach nach dem ersten Archiv ab, und in der Notiz stand nichts; uebrig blieb - # Exit 1. Jetzt laufen Ergebnis, Zeilen und Sicherung beider Teile (erster und letzter Teil), und - # genau eine Zeile von heute nennt lauf.log, mit Exit 1. Ein zweiter Lauf mit demselben Modul und - # schreibbarem lauf.log endet mit 0 und ohne neue Zeile: Der Fehler gilt je Lauf. - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - host = host_ablage(wurzel) - modul = lade() - ablage, typ = lauf_log_sperren(modul, wurzel) - code, notiz, _, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", modul=modul) - ergebnisse = sorted(p.name for p in ablage.glob("*.json")) - assert ergebnisse == [f"{TAG}-ibkr.json", f"{TAG}.json"], "Ergebnisdateien fehlen" - assert "Abendkontrolle Trading Plattform (automatisch): ohne Befund" in notiz, notiz - assert "Abendkontrolle IBKR (automatisch): ohne Befund" in notiz, notiz - ziel = wurzel / "ziel" / TAG - assert namen(ziel) == ["alpaca.tar", "alpaca.tar.sha256", "ibkr.tar", "ibkr.tar.sha256"], namen(ziel) - assert namen(host / "bestaetigt") == [f"{TAG}-alpaca", f"{TAG}-ibkr"], namen(host / "bestaetigt") - assert "**gescheitert**" not in einzige_zeile(notiz), notiz - assert log_zeilen(notiz) == [log_zeile("23:30", typ)], notiz - assert code == 1, code - assert namen(ablage / "lauf.log") == [], namen(ablage / "lauf.log") - # Zweiter Lauf mit demselben Modul; lauf.log ist wieder schreibbar. - (ablage / "lauf.log").rmdir() - code, notiz, _, _ = sicherungslauf(wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:45", modul=modul) - assert code == 0, (code, notiz) - assert len(log_zeilen(notiz)) == 1, notiz - assert (ablage / "lauf.log").is_file(), "lauf.log nach dem zweiten Lauf keine Datei" - - -@fall -def ein_gesperrtes_lauf_log_verschluckt_die_zeile_zum_unerreichbaren_host_nicht(): - # Kritische Pruefung NT, Befund 3: Im Zweig "Docker-Host nicht erreichbar" stand logge vor der Zeile in - # der Notiz; warf es, fehlte die Zeile, und uebrig blieb Exit 1. Jetzt stehen in der Notiz von heute - # die Zeile zum Host und die zu lauf.log, je genau einmal. - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - modul = lade() - ergebnisse_liegen(modul, wurzel) - _, typ = lauf_log_sperren(modul, wurzel) - code, notiz, befehle, _ = sicherungslauf( - wurzel, "--jetzt", "2026-10-12T23:30", modul=modul, erster_tag=date(2026, 10, 12), host_weg="0" - ) - assert len(sicherungszeilen(notiz)) == 1, "Zeile zum unerreichbaren Host fehlt" - assert einzige_zeile(notiz) == ( - f"- 23:30 Uhr — {KOPF}: **gescheitert** #needs-review. Docker-Host nicht erreichbar " - f"(sicherung-summe 2026-10-12-alpaca: Exit 255; {HOST_WEG}), Sicherung abgebrochen. " - "Der nächste Lauf versucht es erneut." - ), notiz - assert log_zeilen(notiz) == [log_zeile("23:30", typ)], notiz - assert namen(wurzel / "vault" / "Inbox") == ["2026-10-12.md"], namen(wurzel / "vault" / "Inbox") - assert befehle == ["sicherung-summe 2026-10-12-alpaca"], befehle - assert code == 1, code - - -@fall -def ein_gesperrtes_lauf_log_verschluckt_die_zeilen_gescheiterter_teile_nicht(): - # Kritische Pruefung NT, Befund 3: Scheiterte ein Teil, stand logge vor seiner Zeile in der Notiz. Hier - # liefert der Host fuer beide Teile kein Ergebnis (erster und letzter Teil), und --tag ist nicht heute: - # Die Zeilen der Teile stehen in der Notiz des Tags, die Zeile zu lauf.log nur in der von heute. - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - modul = lade() - _, typ = lauf_log_sperren(modul, wurzel) - - def kein_ergebnis(tag, teil="alpaca"): - raise RuntimeError(f"auf dem Docker-Host liegt fuer {tag} ({teil}) noch kein Ergebnis") - - modul.fahre_dockerhost = kein_ergebnis - code, _, befehle, _ = sicherungslauf( - wurzel, "--tag", TAG, "--jetzt", "2026-10-06T23:30", modul=modul, erster_tag=None - ) - inbox = wurzel / "vault" / "Inbox" - assert namen(inbox) == [f"{TAG}.md", "2026-10-06.md"], f"Notizen {namen(inbox)}" - tagnotiz = (inbox / f"{TAG}.md").read_text(encoding="utf-8") - heute = (inbox / "2026-10-06.md").read_text(encoding="utf-8") - for kopf in ("Abendkontrolle Trading Plattform (automatisch)", "Abendkontrolle IBKR (automatisch)"): - assert f"- 2026-10-06 23:30 Uhr — {kopf}: **gescheitert** (RuntimeError: " in tagnotiz, tagnotiz - assert log_zeilen(tagnotiz) == [], tagnotiz - assert log_zeilen(heute) == [log_zeile("23:30", typ)], heute - assert befehle == [], befehle - assert code == 1, code - - -@fall -def eine_gesperrte_notiz_neben_gesperrtem_lauf_log_bricht_main_nicht_ab(): - # Paket NR: Laesst sich neben lauf.log auch die Notiz von heute nicht schreiben (ihre Nachbardatei - # ist ein Ordner), endet main ohne Ausnahme mit Exit 1, und die Ergebnisdateien beider Teile liegen. - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - modul = lade() - ablage, _ = lauf_log_sperren(modul, wurzel) - (wurzel / "vault" / "Inbox" / f"{TAG}.md.abendkontrolle.tmp").mkdir(parents=True) - code, _, befehle, _ = sicherungslauf( - wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", modul=modul, erster_tag=None - ) - ergebnisse = sorted(p.name for p in ablage.glob("*.json")) - assert ergebnisse == [f"{TAG}-ibkr.json", f"{TAG}.json"], "Ergebnisdateien fehlen" - assert namen(wurzel / "vault" / "Inbox") == [f"{TAG}.md.abendkontrolle.tmp"], namen(wurzel / "vault" / "Inbox") - assert befehle == [], befehle - assert code == 1, code - - -# --- Seit der Plankritik NR: kein OSError in logge, Ablage nicht anlegbar, Ergebnisdatei nicht schreibbar --- - - -def typ_beim_schreiben(pfad): - """Der Typ der Ausnahme, wenn pfad sich nicht als Datei schreiben laesst; der Fall misst ihn selbst.""" - try: - pfad.write_text("{}", encoding="utf-8") - except OSError as fehler: - return type(fehler).__name__ - raise AssertionError("an dieser Stelle liess sich eine Datei schreiben") - - -def ergebnis_zusatz(typ): - """Die Unterzeile unter der Zeile eines Tages, dessen Ergebnisdatei sich nicht schreiben liess. - - Seit Task 746 (kritische Pruefung NR) sagt sie nur, was faellige_tage hergibt; 7 ist NACHHOLEN_TAGE. - """ - return ( - f" - Ergebnisdatei nicht geschrieben ({typ}) #needs-review. Ein späterer Lauf ohne --tag prüft den Tag " - "nur erneut, wenn er ein Werktag ab dem ersten Prüftag des Teils ist, höchstens 7 Kalendertage " - "zurückliegt und unter dem Namen der Datei nichts liegt, auch keine halb geschriebene. Sonst prüft ihn " - "kein Lauf von selbst." - ) - - -def zeile_mit_folge(notiz, anfang): - """Die eine Zeile der Notizen, die mit anfang beginnt, und die Zeile danach ("" am Ende).""" - zeilen = notiz.splitlines() - treffer = [i for i, zeile in enumerate(zeilen) if zeile.startswith(anfang)] - assert len(treffer) == 1, (anfang, notiz) - i = treffer[0] - return zeilen[i], (zeilen[i + 1] if i + 1 < len(zeilen) else "") - - -@fall -def ein_fehler_in_logge_der_kein_oserror_ist_haelt_den_lauf_nicht_auf(): - # Plankritik NR: logge faengt jede Ausnahme, nicht nur OSError (Entscheidungsliste Task 743, Nr. 1). - # Hier wirft die Uhr in logge einen RuntimeError; die Attrappe gilt nur im geladenen Modul, und --jetzt - # liest dieselbe Klasse heil. Ergebnis und Zeilen beider Teile entstehen, die Zeile zu lauf.log nennt - # den Typ, Exit 1. - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - modul = lade() - - class KaputteUhr(datetime): - @classmethod - def now(cls, tz=None): - raise RuntimeError("Uhr der Attrappe") - - modul.datetime = KaputteUhr - code, notiz, befehle, _ = sicherungslauf( - wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", modul=modul, erster_tag=None - ) - ablage = wurzel / "vault" / modul.ABLAGE_TEIL - ergebnisse = sorted(p.name for p in ablage.glob("*.json")) - assert ergebnisse == [f"{TAG}-ibkr.json", f"{TAG}.json"], "Ergebnisdateien fehlen" - assert "Abendkontrolle Trading Plattform (automatisch): ohne Befund" in notiz, notiz - assert "Abendkontrolle IBKR (automatisch): ohne Befund" in notiz, notiz - assert log_zeilen(notiz) == [log_zeile("23:30", "RuntimeError")], notiz - assert befehle == [], befehle - assert code == 1, code - - -@fall -def eine_ablage_die_sich_nicht_anlegen_laesst_haelt_die_zeilen_nicht_auf(): - # Plankritik NR: logge legt die Ablage im try an (Entscheidungsliste Task 743, Nr. 1). Hier liegt an - # ihrer Stelle eine Datei; weder lauf.log noch die Ergebnisdateien lassen sich schreiben. Mit --tag - # liest der Lauf die Ablage vorher nicht (faellige_tage). Die Zeilen beider Teile stehen mit der - # Unterzeile zur Ergebnisdatei in der Notiz, dazu die Zeile zu lauf.log, Exit 1. - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - modul = lade() - ablage = wurzel / "vault" / modul.ABLAGE_TEIL - ablage.parent.mkdir(parents=True) - ablage.write_bytes(b"keine Ablage\n") - try: - ablage.mkdir(parents=True, exist_ok=True) - except OSError as fehler: - typ_log = type(fehler).__name__ - else: - raise AssertionError("die Ablage liess sich anlegen") - typ_ergebnis = typ_beim_schreiben(ablage / f"{TAG}.json") - code, notiz, befehle, _ = sicherungslauf( - wurzel, "--tag", TAG, "--jetzt", "2026-10-05T23:30", modul=modul, erster_tag=None - ) - inbox = wurzel / "vault" / "Inbox" - assert namen(inbox) == [f"{TAG}.md"], f"Notizen {namen(inbox)}" - for teil in ("alpaca", "ibkr"): - zeile, folge = zeile_mit_folge(notiz, f"- 23:30 Uhr — {modul.KOEPFE[teil]}: ohne Befund.") - assert folge == ergebnis_zusatz(typ_ergebnis), (zeile, folge) - assert log_zeilen(notiz) == [log_zeile("23:30", typ_log)], notiz - assert ablage.read_bytes() == b"keine Ablage\n" - assert befehle == [], befehle - assert code == 1, code - - -@fall -def eine_nicht_schreibbare_ergebnisdatei_haelt_weder_zeile_noch_folgende_tage_auf(): - # Plankritik NR: Die Ergebnisdatei stand in fahre_teil ohne try vor der Zeile in der Notiz. Liess sie - # sich nicht schreiben, flog die Ausnahme aus fahre_teil: Es fehlten die Zeile dieses Tages und die - # aller folgenden Tage des Teils, uebrig blieben "abgebrochen" in lauf.log und Exit 1. Hier liegt an - # ihrer Stelle ein Ordner, bei alpaca am ersten, bei ibkr am letzten faelligen Tag. Die Attrappe legt - # ihn erst nach der Frage an den Host an; vorher zaehlte faellige_tage den Tag als erledigt. - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - modul = lade() - ablage = wurzel / "vault" / modul.ABLAGE_TEIL - ablage.mkdir(parents=True) - for tag in ("2026-09-25", "2026-09-28", "2026-09-29", "2026-09-30"): - (ablage / f"{tag}.json").write_text("{}", encoding="utf-8") - (ablage / f"{tag}-ibkr.json").write_text("{}", encoding="utf-8") - gesperrt = { - ("2026-10-01", "alpaca"): ablage / "2026-10-01.json", - ("2026-10-02", "ibkr"): ablage / "2026-10-02-ibkr.json", - } - echt = modul.fahre_dockerhost - - def fahre_dockerhost(tag, teil="alpaca"): - ergebnis = echt(tag, teil) - if (tag, teil) in gesperrt: - gesperrt[(tag, teil)].mkdir() - return ergebnis - - modul.fahre_dockerhost = fahre_dockerhost - code, notiz, befehle, _ = sicherungslauf( - wurzel, "--jetzt", "2026-10-02T23:30", modul=modul, erster_tag=None - ) - inbox = wurzel / "vault" / "Inbox" - assert namen(inbox) == ["2026-10-01.md", "2026-10-02.md"], f"Notizen {namen(inbox)}" - zusatz = ergebnis_zusatz(typ_beim_schreiben(gesperrt[("2026-10-01", "alpaca")])) - for uhrzeit, teil, gesperrt_hier in ( - ("2026-10-02 23:30", "alpaca", True), - ("2026-10-02 23:30", "ibkr", False), - ("23:30", "alpaca", False), - ("23:30", "ibkr", True), - ): - _, folge = zeile_mit_folge(notiz, f"- {uhrzeit} Uhr — {modul.KOEPFE[teil]}: ohne Befund.") - assert (folge == zusatz) == gesperrt_hier, (uhrzeit, teil, folge) - assert notiz.count("Ergebnisdatei nicht geschrieben") == 2, notiz - assert (ablage / "2026-10-02.json").is_file() and (ablage / "2026-10-01-ibkr.json").is_file(), namen(ablage) - assert all(pfad.is_dir() for pfad in gesperrt.values()), namen(ablage) - assert log_zeilen(notiz) == [], notiz - assert befehle == [], befehle - assert code == 1, code - - -# Mutanten fuer --mutanten: Name, Text in abendkontrolle.py (genau einmal), Ersatz und die Faelle, -# die davon rot werden muessen. MUTANTEN_AUFGABE (unten) tut dasselbe fuer abendkontrolle_einrichten.ps1. -# Ein Mutant, der das Verhalten nicht aendert, gehoert nicht hierher -# (AI/Regelwerk/pruefwerkzeuge.md, Abschnitt 8). -MUTANTEN = [ - ("Summe ungeprueft", " if summe_von(teil) != summe:", " if False:", - ["eine_falsche_summe_wird_nie_abgelegt_und_nie_bestaetigt"]), - ("Exit des Archivs egal", - ' if lauf.returncode != 0:\n raise RuntimeError(f"Archiv nicht geholt', - ' if False:\n raise RuntimeError(f"Archiv nicht geholt', - ["ein_unlesbares_archiv_auf_dem_host_scheitert_nur_fuer_seine_instanz"]), - ("Nachbardatei bleibt", " teil.unlink(missing_ok=True)", " pass", - ["eine_falsche_summe_wird_nie_abgelegt_und_nie_bestaetigt", "eine_abgebrochene_uebertragung_hinterlaesst_nichts"]), - ("Bestaetigung ungeprueft", - ' if lauf.returncode != 0:\n raise RuntimeError(f"abgelegt, aber nicht bestaetigt', - ' if False:\n raise RuntimeError(f"abgelegt, aber nicht bestaetigt', - ["eine_gescheiterte_bestaetigung_laesst_die_summendatei_weg_und_wird_nachgeholt", - "eine_vom_host_abgelehnte_summe_ist_ein_befund_und_wird_neu_geholt"]), - ("alte Summendatei bleibt", " summendatei.unlink(missing_ok=True)", " pass", - ["eine_gescheiterte_bestaetigung_laesst_die_summendatei_weg_und_wird_nachgeholt"]), - ("erster Tag egal", " if erster is None:\n return []", " if False:\n return []", - ["ohne_ersten_sicherungstag_holt_kein_lauf_eine_sicherung", "ergebnis_vom_host_landet_in_ablage_und_notiz"]), - ("Fenster 9 Tage", "SICHERUNG_NACHHOLEN_TAGE = 10", "SICHERUNG_NACHHOLEN_TAGE = 9", - ["faellige_tage_der_sicherung_sind_alle_kalendertage_des_fensters"]), - ("Fenster 11 Tage", "SICHERUNG_NACHHOLEN_TAGE = 10", "SICHERUNG_NACHHOLEN_TAGE = 11", - ["faellige_tage_der_sicherung_sind_alle_kalendertage_des_fensters"]), - ("heute nicht im Fenster", "range(SICHERUNG_NACHHOLEN_TAGE, -1, -1)", "range(SICHERUNG_NACHHOLEN_TAGE, 0, -1)", - ["faellige_tage_der_sicherung_sind_alle_kalendertage_des_fensters"]), - ("erster Tag im Fenster egal", " if kandidat >= erster:", " if True:", - ["faellige_tage_der_sicherung_sind_alle_kalendertage_des_fensters"]), - ("Wochenende nicht im Fenster", " if kandidat >= erster:", - " if kandidat >= erster and kandidat.weekday() < 5:", - ["faellige_tage_der_sicherung_sind_alle_kalendertage_des_fensters"]), - ("Archiv auf D: egal", " if treffer is None or treffer.group(2) != archiv.name or not archiv.is_file():", - " if treffer is None or treffer.group(2) != archiv.name:", - ["fremder_inhalt_im_zielordner_bleibt_und_zaehlt_nicht_als_sicherung"]), - ("Name auf D: ungeprueft", " if treffer is None or treffer.group(2) != archiv.name or not archiv.is_file():", - " if treffer is None or not archiv.is_file():", - ["fremder_inhalt_im_zielordner_bleibt_und_zaehlt_nicht_als_sicherung"]), - ("nie vollstaendig", " if summe_auf_d(ziel / tag, instanz) == summe:", " if False:", - ["eine_vollstaendige_sicherung_auf_d_wird_nicht_nochmal_geholt", "nur_die_fehlende_instanz_wird_geholt"]), - ("Summe des Hosts egal", " if summe_auf_d(ziel / tag, instanz) == summe:", - " if summe_auf_d(ziel / tag, instanz) is not None:", - ["ein_neu_gesicherter_tag_wird_neu_geholt_und_ersetzt"]), - ("Zeile fuer jeden Tag", " if not (gescheitert or getan):\n continue", - " if False:\n continue", - ["eine_vollstaendige_sicherung_auf_d_wird_nicht_nochmal_geholt", "exit_3_fuer_heute_und_das_wochenende_ist_still"]), - ("Quelle egal", - ' if ((argumente.quelle_ibkr or argumente.quelle) if teil == "ibkr" else argumente.quelle) == "dockerhost"', - " if True", ["nur_teile_mit_quelle_dockerhost_holen_eine_sicherung"]), - ("Exit 3 ohne Grund", " if lauf.returncode == 3:\n return None", " if False:\n return None", - ["exit_3_heisst_noch_keine_sicherung_auf_dem_host", "exit_3_fuer_heute_und_das_wochenende_ist_still"]), - ("Exit 3 immer still", " if ohne_sicherung_still(tag, jetzt):", " if True:", - ["exit_3_heisst_noch_keine_sicherung_auf_dem_host"]), - ("heute nicht still", " return kandidat >= jetzt.date() or kandidat.weekday() >= 5", - " return kandidat > jetzt.date() or kandidat.weekday() >= 5", - ["exit_3_fuer_heute_und_das_wochenende_ist_still"]), - ("Wochenende nicht still", " return kandidat >= jetzt.date() or kandidat.weekday() >= 5", - " return kandidat >= jetzt.date()", ["exit_3_fuer_heute_und_das_wochenende_ist_still"]), - ("Fehlschlag ohne Markierung", "**gescheitert** #needs-review. {'; '.join(stuecke)}", - "**gescheitert**. {'; '.join(stuecke)}", ["eine_falsche_summe_wird_nie_abgelegt_und_nie_bestaetigt"]), - ("Fehlschlag reisst den Tag ab", - " except Exception as ausnahme:\n gescheitert = True", - " except ZeroDivisionError as ausnahme:\n gescheitert = True", - ["eine_falsche_summe_wird_nie_abgelegt_und_nie_bestaetigt", "ein_tagesordner_der_eine_datei_ist_ist_ein_befund"]), - ("Zielschalter egal", - " ziel = Path(argumente.sicherung_ziel) if argumente.sicherung_ziel else SICHERUNG_ZIEL", - " ziel = SICHERUNG_ZIEL", ["sicherung_wird_geholt_geprueft_abgelegt_und_bestaetigt"]), - ("Rueckfall nicht auf die Konstante", - " ziel = Path(argumente.sicherung_ziel) if argumente.sicherung_ziel else SICHERUNG_ZIEL", - ' ziel = Path(argumente.sicherung_ziel) if argumente.sicherung_ziel else SICHERUNG_ZIEL / "x"', - ["ohne_schalter_gilt_das_ziel_der_konstante"]), - ("Ziel der Konstante", r'SICHERUNG_ZIEL = Path(r"D:\Trading-Sicherung")', - r'SICHERUNG_ZIEL = Path(r"D:\Trading-Sicherung-alt")', ["ohne_schalter_gilt_das_ziel_der_konstante"]), - # Seit Paket NT (Quorum 4): Host nicht erreichbar, --tag frueh geprueft, Kommentar ohne (S3). - ("E729-01 Host weg: Exit 255 wie jeder andere", " if lauf.returncode == 255:", " if False:", - ["ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz", - "eine_abgebrochene_uebertragung_hinterlaesst_nichts"]), - ("E729-02 Host weg: Zeitlimit wie jeder Fehler", " except subprocess.TimeoutExpired:", - " except ZeroDivisionError:", ["ein_zeitlimit_beim_host_zaehlt_wie_ein_unerreichbarer_host"]), - ("E729-03 Host weg reisst nur die Instanz ab", " except HostNichtErreichbar as ausnahme:", - " except ZeroDivisionError as ausnahme:", - ["ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz", - "ein_host_der_mitten_im_lauf_wegfaellt_laesst_die_zeilen_davor_stehen"]), - ("E729-04 Host weg: Zeile in der Notiz des Tags", - " schreibe_notiz(vault, jetzt.date().isoformat(), zeile)", - " schreibe_notiz(vault, kandidat, zeile)", - ["ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz"]), - ("E729-05 Host weg mit Exit 0", - ' logge(ablage, f"sicherung: Notiz nicht geschrieben {zweite!r}")\n return 1', - ' logge(ablage, f"sicherung: Notiz nicht geschrieben {zweite!r}")\n return 0', - ["ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz"]), - ("E729-06 Host weg: weiter mit der naechsten Instanz", - ' logge(ablage, f"sicherung: Notiz nicht geschrieben {zweite!r}")\n return 1', - ' logge(ablage, f"sicherung: Notiz nicht geschrieben {zweite!r}")\n continue', - ["ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz"]), - ("E729-07 Host weg: nichts vom angefangenen Tag", " if stuecke:\n zeile +=", - " if False:\n zeile +=", - ["ein_host_der_mitten_im_lauf_wegfaellt_laesst_die_zeilen_davor_stehen"]), - ("E729-08 Host weg ohne lauf.log", - ' logge(ablage, f"sicherung {kandidat}-{instanz}: Docker-Host nicht erreichbar ({ausnahme}), ' - 'Sicherung abgebrochen")', - " pass", ["ein_unerreichbarer_host_gibt_eine_zeile_von_heute_und_keine_weitere_notiz"]), - ("E729-09 Tag spaet geprueft", " if argumente.tag is not None and not tag_gueltig(argumente.tag):", - " if False:", ["ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh"]), - ("E729-10 leerer Tag gilt als keiner", " if argumente.tag is not None and not tag_gueltig(argumente.tag):", - " if argumente.tag and not tag_gueltig(argumente.tag):", - ["ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh"]), - ("E729-11 falscher Tag mit Exit 1", - ' print(f"abendkontrolle: --tag {argumente.tag!a} ist kein Datum JJJJ-MM-TT", file=sys.stderr)\n' - " return 2", - ' print(f"abendkontrolle: --tag {argumente.tag!a} ist kein Datum JJJJ-MM-TT", file=sys.stderr)\n' - " return 1", - ["ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh"]), - ("E729-12 falscher Tag ohne Meldung", - ' print(f"abendkontrolle: --tag {argumente.tag!a} ist kein Datum JJJJ-MM-TT", file=sys.stderr)', - " pass", ["ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh"]), - ("E729-13 Form des Tags ungeprueft", " if not TAGFORM.fullmatch(tag):\n return False", - " if False:\n return False", ["ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh"]), - ("E729-14 Datum des Tags ungeprueft", " date.fromisoformat(tag)\n except ValueError:", - " pass\n except ValueError:", ["ein_tag_ausser_der_form_endet_mit_zwei_ohne_notiz_und_ohne_ssh"]), - ("E729-15 Verweis S3 zurueck", - "# Die Sicherung entsteht 16:45 New Yorker Zeit, in Berlin meist 22:45, vom 25.10. bis 31.10.2026", - "# (S3). Die Sicherung entsteht 16:45 New Yorker Zeit, in Berlin meist 22:45, vom 25.10. bis 31.10.2026", - ["kein_kommentar_beginnt_mit_s3"]), - # Seit der Plankritik NT, Befund 1: Ein Abbruch im Archiv laesst nur die Instanz scheitern. - ("E729-25 Abbruch im Archiv beendet die Sicherung", " except HostNichtErreichbar as abbruch:", - " except ZeroDivisionError as abbruch:", - ["ein_zeitlimit_beim_archiv_laesst_nur_diese_instanz_scheitern", - "eine_abgebrochene_uebertragung_hinterlaesst_nichts"]), - # Seit der kritischen Pruefung NT, Befund 1: Eine Bestaetigung ohne Host bricht die Sicherung ab. - ("E739-01 Bestaetigung ohne Host nur fuer die Instanz", - ' lauf = ssh_lauf(f"sicherung-bestaetigt {name} {summe}", SSH_TIMEOUT_S)', - ' try:\n lauf = ssh_lauf(f"sicherung-bestaetigt {name} {summe}", SSH_TIMEOUT_S)\n' - ' except HostNichtErreichbar as abbruch:\n raise RuntimeError(f"nicht bestaetigt ({abbruch})") from None', - ["eine_bestaetigung_ohne_host_bricht_die_sicherung_ab"]), - # Seit Paket NR (kritische Pruefung NT, Befund 3): lauf.log nicht schreibbar. - ("E743-01 lauf.log wirft wieder", - " except Exception as ausnahme:\n LOG_FEHLER = type(ausnahme).__name__", - " except ZeroDivisionError as ausnahme:\n LOG_FEHLER = type(ausnahme).__name__", - ["ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf", - "ein_gesperrtes_lauf_log_verschluckt_die_zeile_zum_unerreichbaren_host_nicht", - "ein_gesperrtes_lauf_log_verschluckt_die_zeilen_gescheiterter_teile_nicht", - "eine_gesperrte_notiz_neben_gesperrtem_lauf_log_bricht_main_nicht_ab"]), - ("E743-02 keine Zeile zu lauf.log", " if LOG_FEHLER is not None:\n code = 1", - " if False:\n code = 1", - ["ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf", - "ein_gesperrtes_lauf_log_verschluckt_die_zeile_zum_unerreichbaren_host_nicht", - "ein_gesperrtes_lauf_log_verschluckt_die_zeilen_gescheiterter_teile_nicht"]), - ("E743-03 Exit 0 trotz lauf.log", " if LOG_FEHLER is not None:\n code = 1", - " if LOG_FEHLER is not None:\n pass", - ["ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf"]), - ("E743-04 Fehler des vorigen Laufs bleibt", " LOG_FEHLER = None", " pass", - ["ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf"]), - ("E743-05 Zeile in der Notiz des Tags", - " schreibe_notiz(vault, jetzt.date().isoformat(), log_zeile)", - " schreibe_notiz(vault, argumente.tag or jetzt.date().isoformat(), log_zeile)", - ["ein_gesperrtes_lauf_log_verschluckt_die_zeilen_gescheiterter_teile_nicht"]), - ("E743-06 Zeile ohne Typ", "**nicht schreibbar** ({LOG_FEHLER}) #needs-review", "**nicht schreibbar** #needs-review", - ["ein_gesperrtes_lauf_log_haelt_ergebnis_notiz_und_sicherung_nicht_auf", - "ein_gesperrtes_lauf_log_verschluckt_die_zeile_zum_unerreichbaren_host_nicht", - "ein_gesperrtes_lauf_log_verschluckt_die_zeilen_gescheiterter_teile_nicht"]), - ("E743-07 gesperrte Notiz bricht main ab", - " schreibe_notiz(vault, jetzt.date().isoformat(), log_zeile)\n except Exception:\n pass", - " schreibe_notiz(vault, jetzt.date().isoformat(), log_zeile)\n except ZeroDivisionError:\n pass", - ["eine_gesperrte_notiz_neben_gesperrtem_lauf_log_bricht_main_nicht_ab"]), - # Seit der Plankritik NR: logge faengt jede Ausnahme und legt die Ablage im try an; eine nicht - # schreibbare Ergebnisdatei haelt weder die Zeile ihres Tages noch die folgenden Tage auf. - ("E743-08 lauf.log faengt nur OSError", - " except Exception as ausnahme:\n LOG_FEHLER = type(ausnahme).__name__", - " except OSError as ausnahme:\n LOG_FEHLER = type(ausnahme).__name__", - ["ein_fehler_in_logge_der_kein_oserror_ist_haelt_den_lauf_nicht_auf"]), - ("E743-09 Ablage vor dem try angelegt", - " try:\n ablage.mkdir(parents=True, exist_ok=True)\n", - " ablage.mkdir(parents=True, exist_ok=True)\n try:\n", - ["eine_ablage_die_sich_nicht_anlegen_laesst_haelt_die_zeilen_nicht_auf"]), - ("E743-10 Ergebnisdatei reisst den Teil ab", - ' except Exception as ausnahme:\n code = 1\n logge(ablage, f"{vorsatz}{tag}: Ergebnisdatei', - ' except ZeroDivisionError as ausnahme:\n code = 1\n logge(ablage, f"{vorsatz}{tag}: Ergebnisdatei', - ["eine_nicht_schreibbare_ergebnisdatei_haelt_weder_zeile_noch_folgende_tage_auf", - "eine_ablage_die_sich_nicht_anlegen_laesst_haelt_die_zeilen_nicht_auf"]), - ("E743-11 Exit 0 trotz fehlender Ergebnisdatei", - ' code = 1\n logge(ablage, f"{vorsatz}{tag}: Ergebnisdatei', - ' pass\n logge(ablage, f"{vorsatz}{tag}: Ergebnisdatei', - ["eine_nicht_schreibbare_ergebnisdatei_haelt_weder_zeile_noch_folgende_tage_auf"]), - ("E743-12 Zeile ohne Unterzeile zur Ergebnisdatei", - " schreibe_notiz(vault, tag, zeile_von(ergebnis, uhrzeit) + zusatz)", - " schreibe_notiz(vault, tag, zeile_von(ergebnis, uhrzeit))", - ["eine_nicht_schreibbare_ergebnisdatei_haelt_weder_zeile_noch_folgende_tage_auf", - "eine_ablage_die_sich_nicht_anlegen_laesst_haelt_die_zeilen_nicht_auf"]), - ("E743-13 Unterzeile ohne Typ", "Ergebnisdatei nicht geschrieben ({type(ausnahme).__name__}) #needs-review", - "Ergebnisdatei nicht geschrieben #needs-review", - ["eine_nicht_schreibbare_ergebnisdatei_haelt_weder_zeile_noch_folgende_tage_auf", - "eine_ablage_die_sich_nicht_anlegen_laesst_haelt_die_zeilen_nicht_auf"]), - ("Name der Summendatei beim Host egal", ' if treffer is None or treffer.group(2) != f"{name}.tar":', - " if treffer is None:", ["eine_summendatei_des_hosts_in_falscher_form_holt_kein_archiv"]), - ("Grossbuchstaben in der Summe", r'SUMMENZEILE = re.compile(r"([0-9a-f]{64}) (\S+)\n")', - r'SUMMENZEILE = re.compile(r"([0-9a-fA-F]{64}) (\S+)\n")', - ["eine_summendatei_des_hosts_in_falscher_form_holt_kein_archiv"]), - ("Archiv direkt unter dem endgueltigen Namen", ' teil = ordner / f"{instanz}.tar.teil"', - ' teil = ordner / f"{instanz}.tar"', ["sicherung_wird_geholt_geprueft_abgelegt_und_bestaetigt"]), - ("Summendatei auf D: mit dem Namen des Hosts", - r' neu.write_bytes(f"{summe} {archiv.name}\n".encode("ascii"))', - r' neu.write_bytes(f"{summe} {tag}-{archiv.name}\n".encode("ascii"))', - ["sicherung_wird_geholt_geprueft_abgelegt_und_bestaetigt"]), - ("Zeitlimit des Archivs weg", ' lauf = ssh_lauf(f"sicherung {name}", SICHERUNG_TIMEOUT_S, strom)', - ' lauf = ssh_lauf(f"sicherung {name}", None, strom)', - ["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]), - ("Zeitlimit des Archivs anders", "SICHERUNG_TIMEOUT_S = 600", "SICHERUNG_TIMEOUT_S = 6000", - ["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]), - ("Budget egal", " if monotonic() >= frist:", " if False:", - ["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]), - ("Budget anders", "SICHERUNG_BUDGET_S = 1500", "SICHERUNG_BUDGET_S = 3600", - ["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]), - ("nach dem Budget weiter", " if erschoepft:\n break", " if False:\n break", - ["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]), - ("Budget erst nach der Grenze", " if monotonic() >= frist:", " if monotonic() > frist:", - ["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]), - ("Budget erst ab dem Holen", "hole_sicherungen(jetzt, vault, ablage, ziel, instanzen, frist, argumente.tag)", - "hole_sicherungen(jetzt, vault, ablage, ziel, instanzen, monotonic() + SICHERUNG_BUDGET_S, argumente.tag)", - ["das_zeitbudget_begrenzt_das_holen_und_jedes_archiv_hat_sein_zeitlimit"]), - ("Summendatei vor der Bestaetigung", - ' lauf = ssh_lauf(f"sicherung-bestaetigt {name} {summe}", SSH_TIMEOUT_S)\n' - " if lauf.returncode != 0:\n" - ' raise RuntimeError(f"abgelegt, aber nicht bestaetigt ({fehlerzeile(lauf)})")\n' - ' neu = ordner / f"{instanz}.tar.sha256.neu"\n' - ' neu.write_bytes(f"{summe} {archiv.name}\\n".encode("ascii"))\n' - " os.replace(neu, summendatei)\n", - ' neu = ordner / f"{instanz}.tar.sha256.neu"\n' - ' neu.write_bytes(f"{summe} {archiv.name}\\n".encode("ascii"))\n' - " os.replace(neu, summendatei)\n" - ' lauf = ssh_lauf(f"sicherung-bestaetigt {name} {summe}", SSH_TIMEOUT_S)\n' - " if lauf.returncode != 0:\n" - ' raise RuntimeError(f"abgelegt, aber nicht bestaetigt ({fehlerzeile(lauf)})")\n', - ["eine_gescheiterte_bestaetigung_laesst_die_summendatei_weg_und_wird_nachgeholt", - "eine_vom_host_abgelehnte_summe_ist_ein_befund_und_wird_neu_geholt"]), -] - -# Seit der Plankritik Runde 1: Mutanten von abendkontrolle_einrichten.ps1 (AUFGABE), gleiche Form. -MUTANTEN_AUFGABE = [ - ("Ausloeser mit Versatz", "$Abends.StartBoundary = (Get-Date -Format 'yyyy-MM-dd') + 'T' + $Uhrzeit + ':00'\n", "", - ["der_abendausloeser_steht_in_ortszeit_ohne_versatz", "die_heutige_frist_und_die_aufgabe_passen_zueinander"]), - ("Ausloeser in UTC", "+ $Uhrzeit + ':00'", "+ $Uhrzeit + ':00Z'", - ["der_abendausloeser_steht_in_ortszeit_ohne_versatz"]), -] - - -def mutanten() -> int: - """Jeder Mutant auf einer Kopie seiner Datei; jeder seiner Faelle muss rot werden. - - MUTANTEN aendern abendkontrolle.py (QUELLE), MUTANTEN_AUFGABE abendkontrolle_einrichten.ps1 (AUFGABE). - """ - global QUELLE, AUFGABE - faelle = {funktion.__name__: funktion for funktion in FAELLE} - alle = [("abendkontrolle.py", *mutant) for mutant in MUTANTEN] - alle += [("abendkontrolle_einrichten.ps1", *mutant) for mutant in MUTANTEN_AUFGABE] - - def gruen_bleiben(namen_der_faelle): - gruen = [] - for fallname in namen_der_faelle: - try: - faelle[fallname]() - except (Exception, SystemExit): - continue - gruen.append(fallname) - return gruen - - genannt = sorted({fallname for *_, rot in alle for fallname in rot}) - fehlend = [fallname for fallname in genannt if fallname not in faelle] - if fehlend: - print(f"FALL FEHLT: {fehlend}") - return 2 - # Grundlinie: Ohne Mutant muss jeder genannte Fall gruen sein, sonst misst der Rest nichts. - rot_ohne_mutant = [fallname for fallname in genannt if not gruen_bleiben([fallname])] - if rot_ohne_mutant: - print(f"GRUNDLINIE rot: {rot_ohne_mutant}") - return 2 - ueberlebt = [] - for datei, name, alt, neu, rot in alle: - original = (HIER / datei).read_text(encoding="utf-8") - anzahl = original.count(alt) - if anzahl != 1: - print(f"ANKER {anzahl}-mal: {name}") - ueberlebt.append(name) - continue - with tempfile.TemporaryDirectory() as ordner: - kopie = Path(ordner) / datei - kopie.write_text(original.replace(alt, neu), encoding="utf-8", newline="\n") - if datei == "abendkontrolle.py": - QUELLE = kopie - else: - AUFGABE = kopie - try: - gruen = gruen_bleiben(rot) - finally: - QUELLE = HIER / "abendkontrolle.py" - AUFGABE = HIER / "abendkontrolle_einrichten.ps1" - if gruen: - ueberlebt.append(name) - print(f"UEBERLEBT {name}: gruen blieben {gruen}") - else: - print(f"OK {name}: rot wurden {rot}") - print(f"{len(alle)} Mutanten, {len(ueberlebt)} ueberlebt ({datetime.now():%Y-%m-%d %H:%M})") - return 2 if ueberlebt else 0 - - -def main(argv=None) -> int: - argumente = sys.argv[1:] if argv is None else argv - if argumente == ["--mutanten"]: - return mutanten() - if argumente: - print(f"unbekannte Argumente {argumente}; erlaubt ist nur --mutanten") - return 2 - abweichungen = 0 - for funktion in FAELLE: - try: - funktion() - except (Exception, SystemExit) as ausnahme: - abweichungen += 1 - print(f"ABWEICHUNG {funktion.__name__}: {type(ausnahme).__name__}: {ausnahme}") - else: - print(f"OK {funktion.__name__}") - print(f"{len(FAELLE)} Faelle, {abweichungen} Abweichung(en) ({datetime.now():%Y-%m-%d %H:%M})") - return 2 if abweichungen else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_einrichten.ps1 b/.vault-tools/trading-plattform-pruefung/abendkontrolle_einrichten.ps1 deleted file mode 100644 index d84c968..0000000 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle_einrichten.ps1 +++ /dev/null @@ -1,62 +0,0 @@ -# Richtet die Windows-Aufgabe "Trading Plattform - Abendkontrolle" ein oder entfernt sie. -# Koordinator-Werkzeug, nicht fuers Repo. Auftrag Pascal 2026-09-24: taegliche Abendkontrolle, -# dauerhaft, uebersteht Neustarts, blockiert keine Updates von Claude Code oder Desktop. -# -# Zwei Ausloeser: werktags 22:45 (nach der Tagesauswertung um 22:20) oder statt dessen 23:15, sobald ein Teil vom -# Docker-Host kommt (-Quelle oder -QuelleIbkr dockerhost, seit Paket DH), und fuenf Minuten nach -# jeder Anmeldung (holt einen verpassten Abend nach; abendkontrolle.py prueft nur Tage ohne -# Ergebnisdatei). -# Nur bei angemeldetem Benutzer (Interactive): WSL und der ganze Paper-Betrieb brauchen die Anmeldung -# ohnehin (Aufgabe "Trading Plattform - WSL wachhalten"). pythonw.exe startet ohne Fenster. -# Voller Interpreterpfad: -Execute "python" endet in der Aufgabenplanung mit 0x80070002. -# -# Aufruf: powershell -NoProfile -File abendkontrolle_einrichten.ps1 [-Aktion einrichten|entfernen] -# [-Quelle wsl|dockerhost] [-QuelleIbkr wsl|dockerhost] -param( - [ValidateSet("einrichten", "entfernen")] - [string]$Aktion = "einrichten", - # Seit Paket DH: wsl (Vorgabe) prueft in WSL wie bisher; dockerhost liest das Ergebnis des - # Docker-Hosts ueber SSH, werktags erst um 23:15 (der Host prueft um 16:30 New Yorker Zeit). - [ValidateSet("wsl", "dockerhost")] - [string]$Quelle = "wsl", - # Die Quelle fuer den Teil ibkr; ohne Angabe dieselbe wie -Quelle. Die IBKR-Instanz zieht an - # einem eigenen Abend um (Umzugs-Drehbuch DH, Phase 7). - [ValidateSet("wsl", "dockerhost")] - [string]$QuelleIbkr -) - -$Name = "Trading Plattform - Abendkontrolle" -if ($Aktion -eq "entfernen") { - Unregister-ScheduledTask -TaskName $Name -Confirm:$false - return -} - -$Py = "C:\Users\Pascal Michel\AppData\Local\Programs\Python\Python313\pythonw.exe" -$Skript = "D:\Claude\.vault-tools\trading-plattform-pruefung\abendkontrolle.py" -if (-not (Test-Path $Py)) { throw "Interpreter fehlt: $Py" } -if (-not (Test-Path $Skript)) { throw "Skript fehlt: $Skript" } - -$Benutzer = "$env:USERDOMAIN\$env:USERNAME" -if (-not $QuelleIbkr) { $QuelleIbkr = $Quelle } -$Aufruf = New-ScheduledTaskAction -Execute $Py -Argument ('"' + $Skript + '" --quelle ' + $Quelle + ' --quelle-ibkr ' + $QuelleIbkr) -WorkingDirectory "D:\Claude" -if ($Quelle -eq "dockerhost" -or $QuelleIbkr -eq "dockerhost") { $Uhrzeit = "23:15" } else { $Uhrzeit = "22:45" } -$Abends = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday, Tuesday, Wednesday, Thursday, Friday -At $Uhrzeit -# Seit Paket E9 (S10): -At legt den Startzeitpunkt mit UTC-Versatz an (gemessen 2026-10-11: -# ...T21:15:00Z). Eine Aufgabe mit Versatz startet im Winter eine Stunde frueher nach Ortszeit, vor -# der Abendkontrolle des Hosts. Ohne Versatz gesetzt, speichert die Aufgabenplanung den Wert so und -# startet nach Ortszeit. -$Abends.StartBoundary = (Get-Date -Format 'yyyy-MM-dd') + 'T' + $Uhrzeit + ':00' -$Anmeldung = New-ScheduledTaskTrigger -AtLogOn -User $Benutzer -$Anmeldung.Delay = "PT5M" -$Wer = New-ScheduledTaskPrincipal -UserId $Benutzer -LogonType Interactive -RunLevel Limited -$Regeln = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` - -ExecutionTimeLimit (New-TimeSpan -Minutes 45) -MultipleInstances IgnoreNew -Register-ScheduledTask -TaskName $Name -Action $Aufruf -Trigger @($Abends, $Anmeldung) -Principal $Wer ` - -Settings $Regeln -Force ` - -Description "Abendkontrolle des Paper-Betriebs: liest nur, schreibt in die Tagesnotiz, meldet Befunde per ntfy. Koordinator-Werkzeug, siehe abendkontrolle.py." | Out-Null - -$Aufgabe = Get-ScheduledTask -TaskName $Name -"{0}: {1}" -f $Aufgabe.TaskName, $Aufgabe.State -$Aufgabe.Triggers | ForEach-Object { "Ausloeser: {0} {1} {2}" -f $_.CimClass.CimClassName, $_.StartBoundary, $_.Delay } -"Naechster Lauf: {0}" -f (Get-ScheduledTaskInfo -TaskName $Name).NextRunTime -"Quellen: {0} (alpaca), {1} (ibkr)" -f $Quelle, $QuelleIbkr diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_ersatz_auswertung.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle_ersatz_auswertung.py deleted file mode 100644 index 0ae9bf4..0000000 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle_ersatz_auswertung.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Ersatz fuer handelstag_auswertung.py in den Angriffsfaellen der Abendkontrolle: druckt ERSATZ_AUSGABE, -endet mit ERSATZ_EXIT. Geht nicht ans Netz. Koordinator-Werkzeug, nicht fuers Repo.""" -import os -import sys - -print(os.environ.get("ERSATZ_AUSGABE", "")) -sys.exit(int(os.environ.get("ERSATZ_EXIT", "0"))) diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_ibkr_python_angriffe.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle_ibkr_python_angriffe.py deleted file mode 100644 index caa5cea..0000000 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle_ibkr_python_angriffe.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Angriffsfaelle fuer --ibkr-python von abendkontrolle_wsl.py (Nachtrag zu Paket DH, IB2), in WSL, ohne Netz. - -Koordinator-Werkzeug, nicht fuers Repo. Auf dem Docker-Host laeuft der Teil ibkr im Abbild; dort -gibt es das Venv aus WSL nicht, und ibkr_pruefen.py lesen laeuft mit dem python des Containers -(abendkontrolle-host.sh: --ibkr-python python). Die Faelle pruefen den Schalter, die Vorgabe, den -Aufruf mit dem genannten Interpreter gegen ein Ersatzskript in einem Wegwerf-Ordner und die -Weitergabe im Ablauf. Das Ersatzskript druckt den Interpreter, der es startet; so faellt ein -Aufruf mit dem festen Venv-Pfad auf, auch wenn es den Pfad gibt. -Aufruf (WSL, Venv): python abendkontrolle_ibkr_python_angriffe.py -Exit 0 ohne Abweichung, 2 mit Abweichung. -""" - -import importlib.util -import sys -import tempfile -from datetime import datetime -from pathlib import Path - -HIER = Path(__file__).resolve().parent -ERSATZ = ( - "import sys\n" - "print('Interpreter: ' + sys.executable)\n" - "print('Positionen: 0')\n" - "print('Offene Orders: 0')\n" -) - -FAELLE = [] - - -def fall(funktion): - FAELLE.append(funktion) - return funktion - - -def lade(): - spec = importlib.util.spec_from_file_location("abendkontrolle_wsl_unter_angriff", HIER / "abendkontrolle_wsl.py") - modul = importlib.util.module_from_spec(spec) - spec.loader.exec_module(modul) - # Zweite Sperre (Pruefung DH-Nachtrag, 2026-09-26): Die Faelle geben den Ordner mit dem - # Ersatzskript ausdruecklich mit. Nimmt ein Mutant stattdessen die Vorgabe, zeigt sie ins Leere - # und nicht auf den Betriebsordner mit dem echten ibkr_pruefen.py. - modul.IBKR_ORDNER = "/nicht-vorhanden/trading-ibkr-betrieb" - return modul - - -@fall -def der_schalter_nennt_den_interpreter(): - argumente = lade().parse_args(["--tag", "2026-10-05", "--teil", "ibkr", "--ibkr-python", "/usr/local/bin/python"]) - assert argumente.ibkr_python == "/usr/local/bin/python", argumente - - -@fall -def ohne_schalter_bleibt_das_venv_aus_wsl(): - modul = lade() - argumente = modul.parse_args(["--tag", "2026-10-05", "--teil", "ibkr"]) - assert argumente.ibkr_python == modul.IBKR_PY, argumente - assert modul.IBKR_PY == "/home/claude/venvs/trading-ibkr/bin/python", modul.IBKR_PY - - -@fall -def lesen_laeuft_mit_dem_genannten_interpreter(): - modul = lade() - with tempfile.TemporaryDirectory() as ordner: - wurzel = Path(ordner) - (wurzel / "scripts").mkdir() - (wurzel / "scripts" / "ibkr_pruefen.py").write_text(ERSATZ, encoding="utf-8") - code, text = modul.lies_konto_ibkr(wurzel, None, sys.executable) - assert code == 0, (code, text) - assert f"Interpreter: {sys.executable}" in text, text - assert "Positionen: 0" in text and "Offene Orders: 0" in text, text - - -@fall -def der_ablauf_gibt_den_schalter_weiter(): - quelle = (HIER / "abendkontrolle_wsl.py").read_text(encoding="utf-8") - assert "lies_konto_ibkr, ordner, argumente.ibkr_konto, argumente.ibkr_python)" in quelle - assert '[python, "scripts/ibkr_pruefen.py", "lesen"], cwd=ordner,' in quelle - - -def main() -> int: - abweichungen = 0 - for funktion in FAELLE: - try: - funktion() - except (Exception, SystemExit) as ausnahme: - abweichungen += 1 - print(f"ABWEICHUNG {funktion.__name__}: {type(ausnahme).__name__}: {ausnahme}") - else: - print(f"OK {funktion.__name__}") - print(f"{len(FAELLE)} Faelle, {abweichungen} Abweichung(en) ({datetime.now():%Y-%m-%d %H:%M})") - return 2 if abweichungen else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_mutanten.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle_mutanten.py deleted file mode 100644 index 91d69bb..0000000 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle_mutanten.py +++ /dev/null @@ -1,218 +0,0 @@ -"""Mutanten der Abendkontrolle und des Rueckwegs: jeder muss seinen Angriffstreiber rot machen. - -Koordinator-Werkzeug, nicht fuers Repo. Jeder Mutant laeuft auf einer Wegwerfkopie dieses Verzeichnisses. -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 -import tempfile -from pathlib import Path - -HIER = Path(__file__).resolve().parent - -WSL_MUTANTEN = [ - ("Exit-Vergleich verkehrt", "abendkontrolle_wsl.py", 'if satz["exit"] != "0":', 'if satz["exit"] == "9":'), - ("Handelstag immer wahr", "abendkontrolle_wsl.py", 'return any(eintrag["date"] == tag for eintrag in liste)', "return True"), - ("gesperrt nicht geprueft", "abendkontrolle_wsl.py", 'if zeile[0] == "gesperrt":', 'if zeile[0] == "nie":'), - ("unbekannter Zustand still", "abendkontrolle_wsl.py", "elif zeile[0] not in KILLSWITCH_ZUSTAENDE:", "elif False:"), - ("Broker-Schwelle", "abendkontrolle_wsl.py", "if offen[-1] != 0:", "if offen[-1] > 5:"), - ("Probe zaehlt mit", "abendkontrolle_wsl.py", 'glob(f"{tag}T*-paper.jsonl")', 'glob(f"{tag}T*.jsonl")'), - ("Maske aus", "abendkontrolle_wsl.py", " text = muster.sub(ersatz, text)", " pass"), - ("nie-aktiv aus", "abendkontrolle_wsl.py", 'if satz.get("killswitch") == "aktiv":', "if True:"), - ("Bericht nicht geprueft", "abendkontrolle_wsl.py", "if not bericht.is_file():\n k.befund", "if False:\n k.befund"), - ("Failed-Zeile aus", "abendkontrolle_wsl.py", 'if "Failed with result" in text:', 'if "nie da" in text:'), - ("nur letzter Lauf im Journal", "abendkontrolle_wsl.py", "for nummer, satz in enumerate(enden, start=1):", - "for nummer, satz in list(enumerate(enden, start=1))[-1:]:"), - ("Melder-Rueckgabe verworfen", "abendkontrolle_wsl.py", - 'return "gesendet" if fehler is None else f"nicht gesendet: {fehler}"', 'return "gesendet"'), - ("Ende je Protokoll nicht bewertet", "abendkontrolle_wsl.py", - " if ende.get(\"grund\") != \"boersenschluss\":\n k.befund(f\"{lauf['datei']}", - " if False:\n k.befund(f\"{lauf['datei']}"), - ("wartezeit im Journal mitgezaehlt", "abendkontrolle_wsl.py", - 'enden = [satz for satz in alle if satz["grund"] != "wartezeit"]', "enden = alle"), - ("Lauf nach Schluss als Handelslauf", "abendkontrolle_wsl.py", - 'return bool(lauf["ende"]) and lauf["ende"].get("grund") == "wartezeit" and lauf["takte"] == 0', "return False"), - ("Fragen nicht voneinander getrennt", "abendkontrolle_wsl.py", - " except Exception as ausnahme:\n self.befund(f\"{name} nicht geprueft", - " except ZeroDivisionError as ausnahme:\n self.befund(f\"{name} nicht geprueft"), - # Seit Paket IB2: der Teil ibkr. - ("IBKR Ordner egal", "abendkontrolle_wsl.py", - " if not ordner.is_dir():\n k.befund(f\"Betriebsordner", " if False:\n k.befund(f\"Betriebsordner"), - ("IBKR Marke egal", "abendkontrolle_wsl.py", ' if marke != "ibkr":', " if False:"), - ("IBKR gesperrt egal", "abendkontrolle_wsl.py", 'if zustand == "gesperrt":', 'if zustand == "nie":'), - ("IBKR Register nicht gelesen", "abendkontrolle_wsl.py", - ' k.schritt("IBKR-Killswitch", pruefe_register_ibkr, ordner, k)', " pass"), - ("IBKR Schlussposition egal", "abendkontrolle_wsl.py", - ' elif ende.get("position") != 0:', " elif False:"), - ("IBKR Lauf ohne ende still", "abendkontrolle_wsl.py", - ' if ende is None:\n # Im Alpaca-Teil meldet das', ' if False:\n # Im Alpaca-Teil meldet das'), - ("IBKR Zeitgeber zaehlt nicht", "abendkontrolle_wsl.py", - 'if not dateien and zeitgeber is not None and zeitgeber != "active":', "if not dateien:"), - ("IBKR nicht gestartet als Befund", "abendkontrolle_wsl.py", 'k.hinweis(f"{NICHT_GESTARTET} (', 'k.befund(f"{NICHT_GESTARTET} ('), - ("IBKR Positionen egal", "abendkontrolle_wsl.py", " if positionen != 0:", " if positionen > 5:"), - ("IBKR Orders egal", "abendkontrolle_wsl.py", " if orders != 0:", " if False:"), - ("IBKR fehlende Kontozeile egal", "abendkontrolle_wsl.py", " if positionen is None or orders is None:", " if False:"), - ("IBKR Kontonummer sichtbar", "abendkontrolle_wsl.py", - 'return maskiere(KONTONUMMER.sub("[konto]", text))', "return maskiere(text)"), - ("IBKR Handelsfenster egal", "abendkontrolle_wsl.py", - " return not IBKR_SPERRE_VON <= (ny.hour, ny.minute) < IBKR_SPERRE_BIS", " return True"), - ("IBKR Journal mit Alpaca-Dienst", "abendkontrolle_wsl.py", "pruefe_journal(text, k, IBKR_DIENST)", "pruefe_journal(text, k)"), - ("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(', - '(ablage / f"{tag}{endung}.nix").write_text('), - ("Wochenende mit", "abendkontrolle.py", "if tag < erster_tag or tag.weekday() >= 5:", "if tag < erster_tag:"), - # Seit Paket IB2: der Teil ibkr. - ("IBKR in die Alpaca-Datei", "abendkontrolle.py", 'IBKR_ENDUNG = "-ibkr"', 'IBKR_ENDUNG = ""'), - ("IBKR mit Alpaca-Kopf", "abendkontrolle.py", ' "ibkr": "Abendkontrolle IBKR (automatisch)",', - ' "ibkr": "Abendkontrolle Trading Plattform (automatisch)",'), - ("IBKR nicht gestartet unsichtbar", "abendkontrolle.py", ' if fakten.get("nicht_gestartet") and not befunde:', " if False:"), - ("IBKR ohne eigenen Starttag", "abendkontrolle.py", "erster_tag=IBKR_ERSTER_TAG, endung=IBKR_ENDUNG", "endung=IBKR_ENDUNG"), - ("mit --ohne-wsl beide Teile", "abendkontrolle.py", '(["alpaca"] if argumente.ohne_wsl else list(TEILE))', "list(TEILE)"), - ("IBKR-Zeile ohne Konto", "abendkontrolle.py", ' konto = fakten.get("konto") or {}', " konto = {}"), - ("Teil ungeprueft", "abendkontrolle.py", 'if ergebnis.get("teil", "alpaca") != teil:', "if False:"), - ("heute vor 22:30", "abendkontrolle.py", "if tag == jetzt.date() and jetzt.time() < FRUEHESTENS:", "if False:"), - ("Befund ohne Markierung", "abendkontrolle.py", "**{len(befunde)} Befund(e)** #needs-review", "{len(befunde)} Befund(e)"), - ("CRLF vergessen", "abendkontrolle.py", 'cr = "\\r" if any(z.endswith("\\r") for z in zeilen) else ""', 'cr = ""'), - ("Formpruefung aus", "abendkontrolle.py", - 'if not isinstance(ergebnis, dict) or not isinstance(ergebnis.get("befunde"), list):', "if False:"), - ("Ueberschrift nur exakt", "abendkontrolle.py", - "if z == UEBERSCHRIFT or z.startswith(UEBERSCHRIFT + \" \")), None)", "if z == UEBERSCHRIFT), None)"), - ("Abbruch gilt als erledigt", "abendkontrolle.py", 'if ergebnis.get("exit") != 2:', "if True:"), - ("nicht atomar", "abendkontrolle.py", " os.replace(neu, notiz)", " notiz.write_bytes(text.encode(\"utf-8\"))"), - ("Zeit egal", "rueckweg_aapl.py", "if jetzt < FRUEHESTENS:", "if False:"), - ("Runner egal", "rueckweg_aapl.py", " if runner_aktiv(runner_vorgabe):", " if False and runner_aktiv(runner_vorgabe):"), - ("unlesbar gilt als steht", "rueckweg_aapl.py", - 'raise RuntimeError(f"Zustand des Runners nicht lesbar: {zustand!r}")', "return False"), - ("schmutziger Ordner egal", "rueckweg_aapl.py", " else:\n if geaendert:", " else:\n if False:"), - ("folge egal", "rueckweg_aapl.py", 'if MEHRERE not in ziel.read_text(encoding="utf-8"):', "if False:"), - ("Ergebnisdatei egal", "rueckweg_aapl.py", 'if (ablage / "rueckweg.json").exists():', "if False:"), - ("kein Commit", "rueckweg_aapl.py", '("add", ZIEL.as_posix()), ("commit", "-m", NACHRICHT)', '("add", ZIEL.as_posix()),'), - ("halb getauscht gilt als fertig", "rueckweg_aapl.py", - " if not geaendert:\n return \"config/paper.toml ist schon", - " if True:\n return \"config/paper.toml ist schon"), - ("Schluesselvergleich aus", "rueckweg_aapl.py", " if fehlend:", " if False:"), - ("Zweig egal", "rueckweg_aapl.py", " if zweig != ZWEIG:", " if False:"), - ("Wertevergleich aus", "rueckweg_aapl.py", " elif neu[name] != alt[name]:", " elif False:"), - ("Gegenrichtung aus", "rueckweg_aapl.py", " elif name not in alt:", " elif False:"), -] -TREIBER = { - "abendkontrolle_wsl.py": "abendkontrolle_angriffe.py", - "abendkontrolle.py": "abendkontrolle_angriffe.py", - "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 = [] -# 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__")) - pfad = ziel / datei - text = pfad.read_text(encoding="utf-8") - if text.count(alt) != 1: - print(f"ANKER {text.count(alt)}-mal: {name}") - 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]), *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 - print(f"{'ROT (gefangen)' if rot else 'GRUEN (UEBERLEBT)'} {name}: {[f[:110] for f in faelle[:2]]}") - if not rot: - ueberlebt.append(name) -print(f"ERGEBNIS: {len(ueberlebt)} ueberlebt von {len(mutanten)} {ueberlebt}") -sys.exit(1 if ueberlebt else 0) diff --git a/.vault-tools/trading-plattform-pruefung/abendkontrolle_wsl.py b/.vault-tools/trading-plattform-pruefung/abendkontrolle_wsl.py deleted file mode 100644 index 8066979..0000000 --- a/.vault-tools/trading-plattform-pruefung/abendkontrolle_wsl.py +++ /dev/null @@ -1,887 +0,0 @@ -"""Abendkontrolle eines Paper-Handelstags: liest nur, urteilt im Code, meldet nur bei Befund. - -Koordinator-Werkzeug, nicht fuers Repo. Laeuft in WSL im Venv des Projekts; gestartet von -abendkontrolle.py (Windows-Aufgabe "Trading Plattform - Abendkontrolle", werktags 22:45 Berlin und -bei jeder Anmeldung). Auftrag Pascal 2026-09-24: taegliche Abendkontrolle, dauerhaft, uebersteht -Neustarts und blockiert keine Updates von Claude Code oder Desktop -- deshalb ohne Claude-Prozess. - -Entscheidungsliste (Frage -> Herkunft der Antwort): - 1. Ist ein Handelstag? Quelle: GET /v2/calendar?start=&end=, gemessen am - 2026-09-24: eine Liste von Objekten mit 'date' (JJJJ-MM-TT) und - 'close' (HH:MM New York); Feiertage fehlen, das Wochenende ist - leer, verkuerzte Tage tragen ein frueheres 'close'. Antwort - nicht lesbar -> Befund, und die Fragen 2 bis 7 bleiben offen. - 2. Lief ein scharfer Runner? Quelle: scripts/paper.py:554, Protokoll - artifacts/paper/-paper.jsonl. Der Start um 09:10 - New York traegt das Datum des Handelstags (gilt, solange - max_wartezeit_h unter etwa 13 h bleibt; heute 8). Keines -> Befund. - 3. Wie endete jeder Lauf? Quelle: scripts/paper.py:517, Zeile "Ende: , Position , - Orders , Killswitch , Exit " im Journal, und die Zeile - 'ende' jedes Protokolls. Projektvorgabe: jeder Lauf endet mit - boersenschluss und Exit 0, der letzte mit Position 0. Ausgenommen - ist ein Lauf ausserhalb der Handelszeit (Ende wartezeit, im - Protokoll ohne Takt; gemessen am 2026-09-20 nach WSL-Neustarts): - nur ein Hinweis. Ein Lauf, der mittags mit abbruch endet, ist ein - Befund, auch wenn der naechste sauber schliesst. Keine Endezeile - im Journal -> Befund; "Failed with result" -> Befund. - 4. Was sagt das Protokoll? Quelle: handelstag_auswertung.py (dieses Verzeichnis); jede - Zeile, die mit "BEFUND" beginnt, ist ein Befund; Exit ungleich 0 - ebenfalls. - 5. Ist das Konto glatt? Quelle: dieselbe Auswertung mit --broker, Zeile "offene - positionen beim broker jetzt: ". n != 0 -> Befund; fehlt die - Zeile -> Befund (die Frage blieb unbeantwortet). - 6. Hat der Runner gehandelt? Projektvorgabe (Pascal 2026-09-24, Paket AF): an einem Handelstag - steht der Killswitch in mindestens einem Takt auf aktiv. Kein - solcher Takt -> Befund "Tag ohne Handel". Takte, die nach dem - ersten aktiven nicht mehr aktiv sind (Watchdog, Pascals Anhalten), - sind ein Hinweis: Der Runner hat dafuer schon selbst gemeldet. - Ereignisse auto_fortsetzen werden berichtet, nicht bewertet. - 7. Liegt der Tagesbericht? Quelle: scripts/tagesauswertung.py:58 und :192, - data/paper/auswertungen/.md. Fehlt -> Befund. - 8. Laufen Dashboard und Zeitgeber? Projektvorgabe (Pakete F und Z): trading-dashboard.service, - trading-paper.timer, trading-auswertung.timer "active"; ein - anderer Zustand wird nach DIENSTE_NOCHMAL_S einmal neu gelesen - (eine eben geweckte WSL startet ihre Dienste noch). - 9. Wie steht der Killswitch? Quelle: Tabelle killswitch_zustand in data/registry.db - (trading/registry/registry.py:281). gesperrt -> Befund: ohne - "Freigeben" handelt der naechste Tag nicht. Ein Zustand - ausserhalb aktiv/angehalten/gesperrt -> Befund. -Jede Frage laeuft fuer sich: Scheitert eine (Sperre im Register, Journal nicht lesbar), ist das ein -Befund, und die anderen werden trotzdem beantwortet. -Meldung: bei mindestens einem Befund eine Meldung mit hoher Prioritaet ueber den Melder des Projekts. -Ob sie ankam, sagt der Rueckgabewert von Melder.melde (None oder der Grund, trading/paper/melder.py); -er steht in fakten.meldung. Kein Alarm entsteht, wenn WSL selbst nicht laeuft -- dann steht nur die -Zeile in der Tagesnotiz (benannte Luecke; der Totmann in Home Assistant, Paket Z2-7, deckt sie ab). -Nicht gesehen: die Guete der Kurse (steht im Bericht), Teilausfuehrungen einzeln, alles vor dem -Handelstag. Betraege gibt das Werkzeug nie aus; fremde Zeilen laufen durch maskiere(). - -Seit Paket IB2 prueft --teil ibkr statt des Alpaca-Runners die IBKR-Instanz in ihrem Betriebsordner -(--ibkr-ordner, Vorgabe IBKR_ORDNER), mit eigener Ausgabe und eigener Meldung (Titel "IBKR ..."): - I1. Handelstag? wie Frage 1, derselbe Kalender von Alpaca. - I2. Lief die Instanz? Protokolle T*-paper.jsonl unter artifacts/paper des Betriebsordners. - Keines und trading-paper-ibkr.timer nicht active -> nur der Hinweis - "IBKR-Instanz heute nicht gestartet", kein Befund, der Rest entfaellt - (No-Go-Tag). Keines bei aktivem oder unlesbarem Zeitgeber -> Befund. - Der Zeitgeber wird zum Zeitpunkt der Kontrolle gelesen, nicht fuer - den geprueften Tag -- beim Nachholen eine benannte Luecke. - I3. Ist der Ordner die Instanz? data/paper/broker.txt sagt ibkr, sonst Befund. - I4. Killswitch der Instanz? killswitch_zustand in data/registry.db des Betriebsordners (mode=ro); - gesperrt -> Befund mit Stufe und Ausloeser des juengsten Ereignisses. - I5. Ende je Lauf, Handel? wie Fragen 3 und 6 (pruefe_laeufe); dazu die Schlussposition jedes - Laufs aus seiner Zeile ende (pruefe_schlusspositionen), unabhaengig - vom Journal: Position != 0 -> Befund. - I6. Journal? wie Frage 3 fuer trading-paper-ibkr.service; jede Zeile mit einem - Muster aus IBKR_MUSTER -> Befund. - I7. Konto am Gateway glatt? scripts/ibkr_pruefen.py lesen im Betriebsordner mit IBKR_PY (Client-ID - 12, lesend), nur werktags ausserhalb 09:00 bis 16:15 New Yorker Zeit - und am Wochenende, sonst ein Hinweis. Weiter gehen nur die Zahlen aus - "Positionen: n" und "Offene Orders: n" und die BEFUND-Zeilen, maskiert, - Kontonummern als [konto]. n != 0, eine fehlende Zeile oder Exit != 0 -> - Befund. -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 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, seit Paket NT - auch eine unlesbare Summendatei oder Bestaetigung) -> 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["-"], - instanz nach --teil; fehlt der Eintrag -> Befund; summe nicht 64 - Hexzeichen in Kleinbuchstaben -> Befund; archiv nicht true (die - Datei .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] [--sicherung ] -Nur eine Meldung schicken: python abendkontrolle_wsl.py --meldung "" "" -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 -text statt des Aufrufs von ibkr_pruefen.py) und --jetzt (ISO-Zeitpunkt mit Versatz). -Seit dem Nachtrag zu Paket DH (IB2): --ibkr-python nennt den Interpreter fuer ibkr_pruefen.py lesen -(Vorgabe IBKR_PY, das Venv in WSL; auf dem Docker-Host python im Abbild, abendkontrolle-host.sh). -Ausgabe: genau eine JSON-Zeile auf stdout. Exit 0 gelaufen (Befunde stehen in der Ausgabe), -2 abgebrochen (die bis dahin gesammelten Befunde stehen trotzdem in der Ausgabe). -""" - -import argparse -import json -import os -import re -import sqlite3 -import subprocess -import sys -import time -from datetime import date -from datetime import datetime -from datetime import timedelta -from pathlib import Path -from zoneinfo import ZoneInfo - -PROJEKT = "/mnt/d/Claude/Code Projekte/active - Trading Plattform" -GEHEIMNISSE = "/home/claude/.config/trading-plattform/secrets.toml" -AUSWERTUNG = Path(__file__).resolve().parent / "handelstag_auswertung.py" -# Nur fuer die Angriffsfaelle: ein Ersatz, der vorgegebene Zeilen druckt, statt ans Netz zu gehen. -AUSWERTUNG_ERSATZ = os.environ.get("ABENDKONTROLLE_AUSWERTUNG") -HANDEL_URL = "https://paper-api.alpaca.markets" -DIENSTE = ("trading-dashboard.service", "trading-paper.timer", "trading-auswertung.timer") -DIENSTE_NOCHMAL_S = 30 -KILLSWITCH_ZUSTAENDE = ("aktiv", "angehalten", "gesperrt") -AUSWERTUNG_TIMEOUT_S = 900 - -# Seit Paket IB2: die IBKR-Instanz im eigenen Betriebsordner (Unit trading-paper-ibkr.service). -IBKR_ORDNER = "/mnt/d/Claude/.arbeitsbaeume/trading-ibkr-betrieb" -IBKR_PY = "/home/claude/venvs/trading-ibkr/bin/python" -IBKR_DIENST = "trading-paper-ibkr.service" -IBKR_ZEITGEBER = "trading-paper-ibkr.timer" -IBKR_MARKE = Path("data") / "paper" / "broker.txt" -IBKR_TITEL = "IBKR " -IBKR_LESEN_TIMEOUT_S = 180 -# Journalzeilen, die ohne Ausnahme ein Befund sind: ein Traceback, und der Satz, mit dem -# scripts/paper.py abbricht, wenn Instanzmarke und Konfiguration nicht zusammenpassen. -IBKR_MUSTER = ("Traceback (most recent call last)", "Nichts wird gebaut") -# Das Konto wird am Gateway nur ausserhalb dieses Fensters gelesen (New Yorker Zeit, werktags): -# Waehrend der Handelszeit gehoerten Positionen und Orders schon zum naechsten Handelstag. -NEW_YORK = ZoneInfo("America/New_York") -IBKR_SPERRE_VON = (9, 0) -IBKR_SPERRE_BIS = (16, 15) -IBKR_POSITIONEN = re.compile(r"^Positionen: (\d+)[ \t\r]*$", re.MULTILINE) -IBKR_ORDERS = re.compile(r"^Offene Orders: (\d+)[ \t\r]*$", re.MULTILINE) -# IBKR-Kontonummern: Buchstaben vorn, dann Ziffern. Das Paper-Konto hat mehr als "DU" vorn; -# die erste Fassung (nur DU/U) traf die echte Nummer nicht (Pruefung IB2, 2026-09-25). Dieselbe -# Form wie im Projekt (trading/broker/ibkr.py). Nie in Notiz oder Meldung. -KONTONUMMER = re.compile(r"\b[A-Z]{1,4}[0-9]{5,}\b") -NICHT_GESTARTET = "IBKR-Instanz heute nicht gestartet" - -ENDE = re.compile( - r"Ende: (?P[^,]+), Position (?P[^,]+), Orders (?P[^,]+), " - r"Killswitch (?P[^,]+), Exit (?P-?\d+)" -) -BROKER_OFFEN = re.compile(r"offene positionen beim broker jetzt: (\d+)") -# Maske fuer alles, was aus fremder Ausgabe in Notiz und Meldung geht, in dieser Reihenfolge: -# - jede Zahl hinter einem Geldwort, -# - jede Zahl vor einer Waehrung, -# - jede Zahl mit mehr als zwei Nachkommastellen, -# - jede Zahl mit mindestens vier Stellen vor dem Komma und Nachkommastellen. -MASKEN = ( - (re.compile(r"(?i)\b(kontowert|konto|startkapital|equity|cash|preis|kurs|usd|pnl)([^0-9-]{0,3})-?[0-9][0-9.,]*"), - lambda m: f"{m.group(1)}{m.group(2)}[x]"), - (re.compile(r"-?\d[\d.,]*\s?(?:USD|EUR|\$|€)"), lambda m: "[x]"), - (re.compile(r"-?\d[\d,]*\.\d{3,}"), lambda m: "[x]"), - (re.compile(r"-?\d{4,}[.,]\d{1,2}\b"), lambda m: "[x]"), -) - - -def maskiere(text: str) -> str: - for muster, ersatz in MASKEN: - text = muster.sub(ersatz, text) - return text - - -class Kontrolle: - def __init__(self) -> None: - self.befunde: list[str] = [] - self.hinweise: list[str] = [] - self.fakten: dict = {} - - def befund(self, text: str) -> None: - self.befunde.append(maskiere(text)) - - def hinweis(self, text: str) -> None: - self.hinweise.append(maskiere(text)) - - def schritt(self, name: str, aufruf, *argumente): - """Eine Frage fuer sich: Scheitert sie, ist das ein Befund, und der Rest laeuft weiter.""" - try: - return aufruf(*argumente) - except Exception as ausnahme: - self.befund(f"{name} nicht geprueft ({type(ausnahme).__name__}: {str(ausnahme)[:160]})") - return None - - -# --- 1 Handelstag --------------------------------------------------------------------------------- - -def lies_kalender(tag: str, kalender_datei: str | None, projekt: Path): - """Die Liste aus /v2/calendar oder aus einer Datei (Angriffsfaelle).""" - if kalender_datei is not None: - return json.loads(Path(kalender_datei).read_text(encoding="utf-8")) - 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": tag, "end": tag}, timeout=30 - ) - antwort.raise_for_status() - return antwort.json() - - -def ist_handelstag(tag: str, liste) -> bool | None: - """True, False, oder None, wenn die Antwort nicht die gemessene Form hat.""" - if not isinstance(liste, list): - return None - for eintrag in liste: - if not isinstance(eintrag, dict) or not isinstance(eintrag.get("date"), str): - return None - return any(eintrag["date"] == tag for eintrag in liste) - - -# --- 2 Protokolle, 3 Ende je Lauf, 6 Handel -------------------------------------------------------- - -def protokolle(projekt: Path, tag: str) -> list[Path]: - return sorted((projekt / "artifacts" / "paper").glob(f"{tag}T*-paper.jsonl")) - - -def lies_protokoll(pfad: Path, k: Kontrolle) -> dict: - takte = aktiv = pausiert_danach = unlesbar = 0 - auto = [] - ende = None - # Dateiiteration statt splitlines: U+2028 in einem Text zerlegte sonst eine JSON-Zeile. - with pfad.open(encoding="utf-8", errors="replace") as datei: - for zeile in datei: - if not zeile.strip(): - continue - try: - satz = json.loads(zeile) - except json.JSONDecodeError: - unlesbar += 1 - continue - if not isinstance(satz, dict): - unlesbar += 1 - continue - art = satz.get("art") - if art == "takt": - takte += 1 - if satz.get("killswitch") == "aktiv": - aktiv += 1 - elif aktiv: - pausiert_danach += 1 - elif art == "ereignis" and satz.get("was") == "auto_fortsetzen": - auto.append(str(satz.get("text"))[:200]) - elif art == "ende": - ende = { - schluessel: satz.get(schluessel) - for schluessel in ("grund", "fehler", "position", "positionen", "orders", "killswitch") - } - if unlesbar: - k.befund(f"{pfad.name}: {unlesbar} Zeile(n) nicht lesbar") - return {"datei": pfad.name, "takte": takte, "takte_aktiv": aktiv, "takte_pausiert_danach": pausiert_danach, - "auto_fortsetzen": auto, "ende": ende} - - -def ausserhalb_der_handelszeit(lauf: dict) -> bool: - """Ein Lauf, der ohne einen einzigen Takt mit wartezeit endete (Start nach Boersenschluss).""" - return bool(lauf["ende"]) and lauf["ende"].get("grund") == "wartezeit" and lauf["takte"] == 0 - - -def pruefe_laeufe(laeufe: list[dict], k: Kontrolle) -> None: - for lauf in laeufe: - ende = lauf["ende"] - if ende is None: - continue # die Auswertung meldet "keine Zeile 'ende'" - if ausserhalb_der_handelszeit(lauf): - k.hinweis(f"{lauf['datei']}: Lauf ausserhalb der Handelszeit (Ende wartezeit), nicht bewertet") - continue - if ende.get("grund") != "boersenschluss": - k.befund(f"{lauf['datei']}: Lauf endete mit {ende.get('grund')} nach {lauf['takte']} Takten") - if ende.get("fehler"): - k.befund(f"{lauf['datei']}: Lauf endete mit Fehler {str(ende.get('fehler'))[:120]}") - if lauf["takte_pausiert_danach"]: - k.hinweis(f"{lauf['datei']}: {lauf['takte_pausiert_danach']} Takte nach dem Fortsetzen nicht aktiv") - for text in lauf["auto_fortsetzen"]: - k.hinweis(f"auto_fortsetzen: {text}") - bewertet = [lauf for lauf in laeufe if not ausserhalb_der_handelszeit(lauf)] - if laeufe and not bewertet: - k.befund("kein Lauf des scharfen Runners in der Handelszeit, nur Laeufe nach Boersenschluss") - elif bewertet and sum(lauf["takte_aktiv"] for lauf in bewertet) == 0: - k.befund("Tag ohne Handel: in keinem Takt stand der Killswitch auf aktiv") - - -# --- 3 Journal ------------------------------------------------------------------------------------- - -def lies_journal(tag: str, journal_datei: str | None, dienst: str = "trading-paper.service") -> str: - if journal_datei is not None: - return Path(journal_datei).read_text(encoding="utf-8", errors="replace") - morgen = (date.fromisoformat(tag) + timedelta(days=1)).isoformat() - lauf = subprocess.run( - ["journalctl", "-u", dienst, "--since", f"{tag} 00:00", "--until", f"{morgen} 06:00", - "-o", "cat", "--no-pager"], - capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60, - ) - if lauf.returncode != 0: - raise RuntimeError(f"journalctl Exit {lauf.returncode}") - return lauf.stdout - - -def pruefe_journal(text: str, k: Kontrolle, dienst: str = "trading-paper.service") -> dict | None: - alle = [{schluessel: wert.strip() for schluessel, wert in m.groupdict().items()} for m in ENDE.finditer(text)] - # Ein Lauf ausserhalb der Handelszeit (Neustart nach Boersenschluss) endet mit wartezeit und Exit 3. - enden = [satz for satz in alle if satz["grund"] != "wartezeit"] - k.fakten["enden_im_journal"] = len(enden) - if len(alle) > len(enden): - k.hinweis(f"{len(alle) - len(enden)} Lauf/Laeufe ausserhalb der Handelszeit im Journal (Ende wartezeit)") - if "Failed with result" in text: - k.befund(f"Journal: {dienst} endete mit 'Failed with result'") - if not enden: - k.befund(f"Journal: keine Zeile 'Ende: …, Exit n' eines Handelslaufs von {dienst} an diesem Tag") - return None - # Jeder Lauf des Tages zaehlt: Ein Runner, der mittags mit einer Stoerung endete und neu gestartet - # wurde, ist auch dann ein Befund, wenn der zweite sauber schliesst. - for nummer, satz in enumerate(enden, start=1): - if satz["exit"] != "0": - k.befund(f"Runner (Lauf {nummer} von {len(enden)}) endete mit Exit {satz['exit']} (Grund {satz['grund']})") - if satz["grund"] != "boersenschluss": - k.befund(f"Runner (Lauf {nummer} von {len(enden)}) endete nicht mit boersenschluss, sondern mit {satz['grund']}") - letzte = enden[-1] - if letzte["position"] != "0": - k.befund(f"Runner endete mit Position {letzte['position']}") - return letzte - - -# --- 4 und 5 Auswertung ---------------------------------------------------------------------------- - -def fahre_auswertung(pfad: Path, projekt: Path, broker: bool, kerzen: bool, k: Kontrolle) -> None: - befehl = [sys.executable, AUSWERTUNG_ERSATZ or str(AUSWERTUNG), str(pfad)] - if broker: - befehl.append("--broker") - if kerzen: - befehl.append("--kerzen") - lauf = subprocess.run( - befehl, cwd=projekt, capture_output=True, text=True, encoding="utf-8", errors="replace", - timeout=AUSWERTUNG_TIMEOUT_S, env={**os.environ, "PYTHONIOENCODING": "utf-8"}, - ) - zeilen = lauf.stdout.splitlines() + lauf.stderr.splitlines() - for zeile in zeilen: - if zeile.strip().startswith("BEFUND") or zeile.startswith("VORAUSSETZUNG FEHLT"): - k.befund(f"{pfad.name}: {zeile.strip()}") - if lauf.returncode != 0: - k.befund(f"{pfad.name}: handelstag_auswertung.py endete mit Exit {lauf.returncode}") - if broker: - offen = [int(m.group(1)) for m in map(BROKER_OFFEN.search, zeilen) if m] - if not offen: - k.befund(f"{pfad.name}: keine Zeile zu offenen Positionen beim Broker -- Konto nicht geprueft") - else: - k.fakten["broker_offen"] = offen[-1] - if offen[-1] != 0: - k.befund(f"Beim Broker stehen {offen[-1]} offene Position(en)") - - -# --- 8 Dienste und 9 Killswitch ------------------------------------------------------------------- - -def lies_dienste(dienste_datei: str | None) -> dict[str, str]: - if dienste_datei is not None: - return json.loads(Path(dienste_datei).read_text(encoding="utf-8")) - - def lesen(): - ergebnis = {} - for dienst in DIENSTE: - lauf = subprocess.run(["systemctl", "is-active", dienst], capture_output=True, text=True, timeout=30) - ergebnis[dienst] = lauf.stdout.strip() or f"exit {lauf.returncode}" - return ergebnis - - zustaende = lesen() - if any(wert != "active" for wert in zustaende.values()): - time.sleep(DIENSTE_NOCHMAL_S) - zustaende = lesen() - return zustaende - - -def pruefe_dienste(zustaende, k: Kontrolle) -> None: - if not isinstance(zustaende, dict): - k.befund("Dienste: Zustand nicht lesbar") - return - k.fakten["dienste"] = zustaende - for dienst in DIENSTE: - if zustaende.get(dienst) != "active": - k.befund(f"{dienst} ist nicht aktiv ({zustaende.get(dienst)})") - - -def pruefe_killswitch(projekt: Path, k: Kontrolle) -> None: - register = projekt / "data" / "registry.db" - if not register.is_file(): - k.befund("Register data/registry.db fehlt") - return - with sqlite3.connect(f"file:{register}?mode=ro", uri=True, timeout=10) as verbindung: - zeile = verbindung.execute( - "SELECT zustand, seit, ausloeser FROM killswitch_zustand WHERE id = 1" - ).fetchone() - if zeile is None: - k.befund("Register: kein Killswitch-Zustand") - return - k.fakten["killswitch"] = {"zustand": zeile[0], "seit": zeile[1], "ausloeser": zeile[2]} - if zeile[0] == "gesperrt": - k.befund("Killswitch steht auf gesperrt: Ohne 'Freigeben' handelt der naechste Tag nicht") - elif zeile[0] not in KILLSWITCH_ZUSTAENDE: - k.befund(f"Killswitch in unbekanntem Zustand {zeile[0]!r}") - - -# --- IBKR-Instanz (seit Paket IB2) ----------------------------------------------------------------- - -def pruefe_marke_ibkr(ordner: Path, k: Kontrolle) -> None: - datei = ordner / IBKR_MARKE - marke = datei.read_text(encoding="utf-8").strip() if datei.is_file() else None - k.fakten["marke"] = marke - if marke != "ibkr": - k.befund( - f"Instanzmarke {IBKR_MARKE.as_posix()} sagt {marke!r} statt 'ibkr': " - "Das ist nicht der Betriebsordner der IBKR-Instanz" - ) - - -def pruefe_register_ibkr(ordner: Path, k: Kontrolle) -> None: - """Zustand und juengstes Ereignis aus dem Register der Instanz, nur lesend (mode=ro).""" - register = ordner / "data" / "registry.db" - if not register.is_file(): - k.befund("IBKR-Register data/registry.db fehlt im Betriebsordner") - return - with sqlite3.connect(f"file:{register}?mode=ro", uri=True, timeout=10) as verbindung: - stand = verbindung.execute( - "SELECT zustand, seit, ausloeser FROM killswitch_zustand WHERE id = 1" - ).fetchone() - letztes = verbindung.execute( - "SELECT stufe, ausloeser FROM killswitch_ereignisse ORDER BY id DESC LIMIT 1" - ).fetchone() - if stand is None: - k.befund("IBKR-Register: kein Killswitch-Zustand") - return - zustand = stand[0] - k.fakten["killswitch"] = {"zustand": zustand, "seit": stand[1], "ausloeser": stand[2]} - if zustand == "gesperrt": - grund = "ohne Ereignis im Register" if letztes is None else f"letztes Ereignis {letztes[0]} von {letztes[1]}" - k.befund(f"IBKR-Killswitch steht auf gesperrt ({grund}): Ohne Freigeben an der Konsole handelt die Instanz nicht") - elif zustand not in KILLSWITCH_ZUSTAENDE: - k.befund(f"IBKR-Killswitch in unbekanntem Zustand {zustand!r}") - - -def pruefe_schlusspositionen(laeufe: list[dict], k: Kontrolle) -> None: - """Die Schlussposition jedes Laufs aus seiner Zeile ende, unabhaengig vom Journal. - - pruefe_laeufe bewertet die Position nicht, und pruefe_journal nur die des letzten Laufs; - der Alpaca-Teil faengt sie ueber handelstag_auswertung.py, das der Teil ibkr nicht faehrt. - Ebenso einen Lauf ohne Zeile ende: pruefe_laeufe ueberspringt ihn, weil im Alpaca-Teil die - Auswertung ihn meldet (Nachkritik IB2). - """ - for lauf in laeufe: - ende = lauf["ende"] - if ende is None: - # Im Alpaca-Teil meldet das handelstag_auswertung.py; hier sonst niemand. - k.befund(f"{lauf['datei']}: Lauf ohne Zeile ende im Protokoll") - elif ende.get("position") != 0: - k.befund(f"{lauf['datei']}: Lauf endete laut Protokoll mit Position {ende.get('position')}") - - -def lies_zeitgeber_ibkr(dienste_datei: str | None) -> str: - """Der Zustand von trading-paper-ibkr.timer (active, inactive, ...); aus einer Datei in den Angriffsfaellen. - - Gelesen wird der Zustand jetzt, nicht der am geprueften Tag: Beim Nachholen kann er sich - inzwischen geaendert haben (benannte Luecke). - """ - if dienste_datei is not None: - zustaende = json.loads(Path(dienste_datei).read_text(encoding="utf-8")) - if not isinstance(zustaende, dict): - raise ValueError("Dienste-Datei ist kein Objekt") - return str(zustaende.get(IBKR_ZEITGEBER, "fehlt")) - lauf = subprocess.run(["systemctl", "is-active", IBKR_ZEITGEBER], capture_output=True, text=True, timeout=30) - return lauf.stdout.strip() or f"exit {lauf.returncode}" - - -def lesen_erlaubt(jetzt: datetime) -> bool: - """Ob der Stand am Gateway jetzt fuer den Tagesschluss spricht: Wochenende, oder werktags - ausserhalb IBKR_SPERRE_VON bis IBKR_SPERRE_BIS New Yorker Zeit.""" - ny = jetzt.astimezone(NEW_YORK) - if ny.weekday() >= 5: - return True - return not IBKR_SPERRE_VON <= (ny.hour, ny.minute) < IBKR_SPERRE_BIS - - -def lies_konto_ibkr(ordner: Path, konto_datei: str | None, python: str = IBKR_PY) -> tuple[int, str]: - """Exit und Ausgabe (stdout und stderr) von scripts/ibkr_pruefen.py lesen, oder aus einer Datei. - - python ist der Interpreter dafuer: in WSL das Venv der Instanz (IBKR_PY), auf dem Docker-Host - das python des Abbilds (--ibkr-python, seit dem Nachtrag zu Paket DH). - """ - if konto_datei is not None: - daten = json.loads(Path(konto_datei).read_text(encoding="utf-8")) - return int(daten["exit"]), str(daten["text"]) - lauf = subprocess.run( - [python, "scripts/ibkr_pruefen.py", "lesen"], cwd=ordner, capture_output=True, text=True, - encoding="utf-8", errors="replace", timeout=IBKR_LESEN_TIMEOUT_S, - env={**os.environ, "PYTHONIOENCODING": "utf-8"}, - ) - return lauf.returncode, lauf.stdout + "\n" + lauf.stderr - - -def ohne_kontonummer(text: str) -> str: - # Erst die Kontonummer, dann die Maske: Sonst machte die Maske aus "Konto DU..." ein - # "Konto DU[x]", und die Kontonummer-Regel faende nichts mehr. - return maskiere(KONTONUMMER.sub("[konto]", text)) - - -def pruefe_konto_ibkr(code: int, text: str, k: Kontrolle) -> None: - """Positionen und offene Orders am Gateway; aus der Ausgabe gehen nur Zahlen und Befunde weiter.""" - treffer_positionen = IBKR_POSITIONEN.search(text) - treffer_orders = IBKR_ORDERS.search(text) - positionen = int(treffer_positionen.group(1)) if treffer_positionen else None - orders = int(treffer_orders.group(1)) if treffer_orders else None - k.fakten["konto"] = {"positionen": positionen, "orders": orders, "exit": code} - for zeile in text.splitlines(): - if zeile.strip().startswith(("BEFUND", "Voraussetzung fehlt")): - k.befund(f"ibkr_pruefen.py lesen: {ohne_kontonummer(zeile.strip())[:200]}") - if code != 0: - k.befund(f"ibkr_pruefen.py lesen endete mit Exit {code}") - if positionen is None or orders is None: - k.befund("Konto am Gateway nicht gelesen: keine Zeile 'Positionen: n' oder 'Offene Orders: n'") - return - if positionen != 0: - k.befund(f"Beim IBKR-Gateway stehen {positionen} Position(en)") - if orders != 0: - k.befund(f"Beim IBKR-Gateway stehen {orders} offene Order(s)") - - -def kontrolliere_ibkr(argumente: argparse.Namespace, k: Kontrolle) -> None: - ordner = Path(argumente.ibkr_ordner) - tag = argumente.tag - date.fromisoformat(tag) # ein falsches Datum ist ein Bedienfehler und bricht ab - k.fakten["ordner"] = str(ordner) - if not ordner.is_dir(): - k.befund(f"Betriebsordner der IBKR-Instanz fehlt: {ordner}") - return - liste = k.schritt("Kalender, und damit Lauf, Ende und Konto,", lies_kalender, tag, argumente.kalender, - Path(argumente.projekt)) - handelstag = None if liste is None else ist_handelstag(tag, liste) - k.fakten["handelstag"] = handelstag - if handelstag is None: - if liste is not None: - k.befund("Kalender in unerwarteter Form; Lauf, Ende und Konto der IBKR-Instanz ungeprueft") - return - dateien = protokolle(ordner, tag) - if not handelstag: - if dateien: - k.hinweis(f"kein Handelstag, aber {len(dateien)} Protokoll(e) der IBKR-Instanz") - return - zeitgeber = k.schritt("Zeitgeber der IBKR-Instanz", lies_zeitgeber_ibkr, argumente.dienste) - k.fakten["zeitgeber"] = zeitgeber - # No-Go-Tag: Pascal hat den Zeitgeber abgeschaltet, und die Instanz lief nicht. Leise. - if not dateien and zeitgeber is not None and zeitgeber != "active": - k.fakten["nicht_gestartet"] = True - k.hinweis(f"{NICHT_GESTARTET} ({IBKR_ZEITGEBER}: {zeitgeber})") - return - k.schritt("Instanzmarke", pruefe_marke_ibkr, ordner, k) - k.schritt("IBKR-Killswitch", pruefe_register_ibkr, ordner, k) - if not dateien: - k.befund(f"kein Protokoll der IBKR-Instanz an einem Handelstag, obwohl {IBKR_ZEITGEBER} " - f"{zeitgeber} ist -- die Instanz lief nicht") - laeufe = [lauf for lauf in (k.schritt(f"Protokoll {pfad.name}", lies_protokoll, pfad, k) for pfad in dateien) if lauf] - k.fakten["laeufe"] = laeufe - pruefe_laeufe(laeufe, k) - pruefe_schlusspositionen(laeufe, k) - text = k.schritt("Journal", lies_journal, tag, argumente.journal, IBKR_DIENST) - if text is not None: - k.fakten["ende"] = pruefe_journal(text, k, IBKR_DIENST) - for muster in IBKR_MUSTER: - if muster in text: - k.befund(f"Journal: {IBKR_DIENST} zeigt '{muster}'") - jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now(NEW_YORK) - if not lesen_erlaubt(jetzt): - k.hinweis(f"Konto am IBKR-Gateway nicht gelesen: {jetzt.astimezone(NEW_YORK):%H:%M} New Yorker Zeit " - "liegt im Handelsfenster") - return - ergebnis = k.schritt("Konto am IBKR-Gateway", lies_konto_ibkr, ordner, argumente.ibkr_konto, argumente.ibkr_python) - if ergebnis is not 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: - """Eine Meldung mit hoher Prioritaet ueber den Melder des Projekts. Scheitert nie nach aussen. - - Melder.melde wirft nicht: Es gibt None bei Erfolg und sonst den Grund zurueck (Typ der Ausnahme - oder "HTTP "). Genau das steht im Ergebnis. - """ - try: - sys.path.insert(0, str(projekt)) - os.chdir(projekt) - from trading.paper.melder import baue_melder - from trading.paper.meldungen import HOCH - from trading.paper.meldungen import Meldung - - melder = baue_melder(config_pfad="config/paper.toml") - if not melder.aktiv: - return "nicht gesendet: Melder nicht aktiv" - fehler = melder.melde(Meldung(titel=titel, text=maskiere(text), prioritaet=HOCH, tags="warning")) - return "gesendet" if fehler is None else f"nicht gesendet: {fehler}" - except Exception as ausnahme: # die Kontrolle haengt nicht an der Meldung - return f"nicht gesendet: {type(ausnahme).__name__}" - - -# --- Ablauf ---------------------------------------------------------------------------------------- - -def kontrolliere(argumente: argparse.Namespace, k: Kontrolle) -> None: - projekt = Path(argumente.projekt) - tag = argumente.tag - date.fromisoformat(tag) # ein falsches Datum ist ein Bedienfehler und bricht ab - # handelstag_auswertung.py und der Melder lesen aus dem Arbeitsverzeichnis (Projektordner). - os.chdir(projekt) - - zustaende = k.schritt("Dienste", lies_dienste, argumente.dienste) - if zustaende is not None: - pruefe_dienste(zustaende, k) - k.schritt("Killswitch", pruefe_killswitch, projekt, k) - - liste = k.schritt("Kalender, und damit Lauf, Ende und Bericht,", lies_kalender, tag, argumente.kalender, projekt) - handelstag = None if liste is None else ist_handelstag(tag, liste) - k.fakten["handelstag"] = handelstag - if handelstag is None: - if liste is not None: - k.befund("Kalender in unerwarteter Form; Lauf, Ende und Bericht ungeprueft") - return - dateien = protokolle(projekt, tag) - if not handelstag: - if dateien: - k.hinweis(f"kein Handelstag, aber {len(dateien)} Protokoll(e) des scharfen Runners") - return - - if not dateien: - k.befund("kein Protokoll des scharfen Runners an einem Handelstag -- der Runner lief nicht") - laeufe = [lauf for lauf in (k.schritt(f"Protokoll {pfad.name}", lies_protokoll, pfad, k) for pfad in dateien) if lauf] - k.fakten["laeufe"] = laeufe - pruefe_laeufe(laeufe, k) - - text = k.schritt("Journal", lies_journal, tag, argumente.journal) - if text is not None: - k.fakten["ende"] = pruefe_journal(text, k) - - ausserhalb = {lauf["datei"] for lauf in laeufe if ausserhalb_der_handelszeit(lauf)} - for pfad in dateien: - if pfad.name in ausserhalb: - continue - k.schritt(f"Auswertung {pfad.name}", fahre_auswertung, pfad, projekt, - not argumente.ohne_broker, not argumente.ohne_kerzen, k) - - bericht = projekt / "data" / "paper" / "auswertungen" / f"{tag}.md" - k.fakten["bericht"] = bericht.is_file() - if not bericht.is_file(): - k.befund(f"Tagesbericht data/paper/auswertungen/{tag}.md fehlt") - - -def parse_args(argv=None) -> argparse.Namespace: - zerleger = argparse.ArgumentParser(description=__doc__.splitlines()[0], allow_abbrev=False) - zerleger.add_argument("--tag") - zerleger.add_argument("--meldung", nargs=2, metavar=("TITEL", "TEXT")) - zerleger.add_argument("--projekt", default=PROJEKT) - zerleger.add_argument("--kalender") - zerleger.add_argument("--journal") - zerleger.add_argument("--dienste") - zerleger.add_argument("--ohne-broker", action="store_true") - zerleger.add_argument("--ohne-kerzen", action="store_true") - zerleger.add_argument("--ohne-meldung", action="store_true") - # Seit Paket IB2: der Teil ibkr prueft die IBKR-Instanz statt des Alpaca-Runners. - zerleger.add_argument("--teil", choices=("alpaca", "ibkr"), default="alpaca") - zerleger.add_argument("--ibkr-ordner", default=IBKR_ORDNER) - # Seit dem Nachtrag zu Paket DH (IB2): der Interpreter fuer ibkr_pruefen.py lesen. - zerleger.add_argument("--ibkr-python", default=IBKR_PY) - # 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") - return argumente - - -def main(argv=None) -> int: - os.environ.setdefault("TRADING_GEHEIMNISSE", GEHEIMNISSE) - argumente = parse_args(argv) - if argumente.meldung: - print(json.dumps({"meldung": melde(*argumente.meldung, Path(argumente.projekt))}, ensure_ascii=False)) - return 0 - - k = Kontrolle() - code = 0 - ibkr = argumente.teil == "ibkr" - try: - if ibkr: - kontrolliere_ibkr(argumente, k) - else: - kontrolliere(argumente, k) - 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)" - k.fakten["meldung"] = melde(IBKR_TITEL + titel if ibkr else titel, text, Path(argumente.projekt)) - print(json.dumps( - {"tag": argumente.tag, "teil": argumente.teil, "befunde": k.befunde, "hinweise": k.hinweise, - "fakten": k.fakten, "exit": code}, - ensure_ascii=False, default=str, - )) - return code - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/abkuerzung_probe.sh b/.vault-tools/trading-plattform-pruefung/abkuerzung_probe.sh deleted file mode 100644 index 8b3a8d0..0000000 --- a/.vault-tools/trading-plattform-pruefung/abkuerzung_probe.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash -# Nimmt argparse eine Abkuerzung von --ausfuehren an? Ausschliesslich aus einem leeren Temp-Verzeichnis: -# Dort endet ein angenommener Schalter an der fehlenden config/risk.toml, bevor irgendetwas laeuft. -# Aufruf: wsl -d Debian -- bash -s < abkuerzung_probe.sh -set -u -P="/mnt/d/Claude/Code Projekte/active - Trading Plattform" -PY="$HOME/venvs/trading/bin/python" -T=$(mktemp -d) -cd "$T" || exit 9 -if [ "$(ls -A "$T" | wc -l)" != "0" ]; then echo "ABBRUCH: Verzeichnis nicht leer"; exit 9; fi -for arg in --a --ausf --ausfuehre; do - "$PY" "$P/scripts/killswitch_uebung.py" "$arg" > "$T/aus.txt" 2> "$T/err.txt" < /dev/null - code=$? - echo "=== $arg: exit=$code" - grep -v -E "Pandas4Warning|utcnow|warnings.warn" "$T/err.txt" | tail -2 - rm -f "$T/aus.txt" "$T/err.txt" -done -echo "Rest im Temp-Verzeichnis: $(ls -A "$T" | wc -l) Eintraege" -rmdir "$T" 2>/dev/null diff --git a/.vault-tools/trading-plattform-pruefung/af_gegenfall.py b/.vault-tools/trading-plattform-pruefung/af_gegenfall.py deleted file mode 100644 index 3a4b045..0000000 --- a/.vault-tools/trading-plattform-pruefung/af_gegenfall.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Paket AF ohne Netz: Gegenfall und Gegenprobe der Fortsetzung zum Tagesstart. - -Koordinator-Werkzeug, nicht Teil des Repos (Paket AF, 2026-09-24). Faehrt die echte -PaperSitzung des Arbeitsbaums mit der ausgelieferten config/paper.toml und ihrem Schalter -[auto_fortsetzen] gegen einen ProbeBroker, mit echten IEX-Kerzen aus einer JSON-Datei von -hole_kerzen.py (fuer jedes Instrument dieselben), einer ProbeUhr, einem Wegwerf-Register -mit der Neustartregel und einem Melder, der nur sammelt. Kein Netz, keine Order an Alpaca, -das echte Register bleibt unberuehrt. - -Drei Faelle: - restposition vor der Oeffnung gestartet, Restposition beim Start -> nicht fortgesetzt - mitten 60 Marktminuten nach der Oeffnung gestartet, flach -> nicht fortgesetzt - flach vor der Oeffnung gestartet, flach -> fortgesetzt (die Gegenprobe) -Exit 0 heisst: Der Fall lief wie erwartet. Jede Abweichung ist eine Zeile BEFUND und Exit 2, -eine fehlende Voraussetzung Exit 3. -Aufruf (WSL, aus dem Arbeitsbaum): python af_gegenfall.py -""" - -import json -import sys -import tempfile -from datetime import datetime -from datetime import timedelta -from pathlib import Path - -sys.path.insert(0, str(Path.cwd())) - -from trading.killswitch.killswitch import Killswitch # noqa: E402 -from trading.killswitch.killswitch import lade_killswitch_config # noqa: E402 -from trading.paper.agenten import agenten_je_instrument # noqa: E402 -from trading.paper.agenten import beschreibung_je_instrument # noqa: E402 -from trading.paper.agenten import lade_agent # noqa: E402 -from trading.paper.config import enge_limits # noqa: E402 -from trading.paper.config import lade_paper_config # noqa: E402 -from trading.paper.kerzen import FesteKerzenquelle # noqa: E402 -from trading.paper.kerzen import Kerze # noqa: E402 -from trading.paper.probe import ProbeBroker # noqa: E402 -from trading.paper.sitzung import PaperSitzung # noqa: E402 -from trading.paper.uhr import ProbeUhr # noqa: E402 -from trading.registry.registry import Registry # noqa: E402 -from trading.risk.limits import lade_limits # noqa: E402 - -FAELLE = ("restposition", "mitten", "flach") -RESTPOSITION = 10 - - -class SammelMelder: - """Nimmt jede Meldung an und schickt nichts.""" - - aktiv = True - - def __init__(self) -> None: - self.gesehen = [] - - def melde(self, meldung): - self.gesehen.append(meldung) - return None - - -class Protokoll: - """Haelt die Zeilen im Speicher, durch json.dumps wie das echte Protokoll.""" - - def __init__(self) -> None: - self.zeilen = [] - - def schreibe(self, eintrag) -> None: - self.zeilen.append(json.loads(json.dumps(eintrag, ensure_ascii=False, default=str))) - - -def lies_kerzen(pfad: Path) -> list: - roh = json.loads(pfad.read_text(encoding="utf-8")) - return [ - Kerze( - zeit=datetime.fromisoformat(k["zeit"]), - open=float(k["open"]), - high=float(k["high"]), - low=float(k["low"]), - close=float(k["close"]), - volume=float(k["volume"]), - ) - for k in roh - ] - - -def main() -> int: - if len(sys.argv) != 3 or sys.argv[2] not in FAELLE: - print(f"Aufruf: python af_gegenfall.py <{'|'.join(FAELLE)}>") - return 3 - pfad, fall = Path(sys.argv[1]), sys.argv[2] - if not pfad.is_file(): - print(f"VORAUSSETZUNG FEHLT: {pfad}") - return 3 - kerzen = sorted(lies_kerzen(pfad), key=lambda kerze: kerze.zeit) - if not kerzen: - print(f"VORAUSSETZUNG FEHLT: keine Kerze in {pfad}") - return 3 - config = lade_paper_config("config/paper.toml") - if not getattr(config, "auto_fortsetzen", False): - print("VORAUSSETZUNG FEHLT: config/paper.toml traegt [auto_fortsetzen] aktiv = true nicht") - return 3 - limits = enge_limits(lade_limits("config/risk.toml"), config) - ks_config = lade_killswitch_config("config/killswitch.toml") - quelle = FesteKerzenquelle(kerzen) - if fall == "mitten": - beginn = kerzen[0].zeit + timedelta(minutes=60) - else: - beginn = kerzen[0].zeit - timedelta(minutes=30) - uhr = ProbeUhr(beginn) - rest = config.alle_instrumente[-1] - broker = ProbeBroker( - kerzenquelle=quelle, - uhr=uhr, - instrument_id=config.instrument_id, - instrumente=config.alle_instrumente, - positionen={rest: RESTPOSITION} if fall == "restposition" else None, - ) - protokoll = Protokoll() - melder = SammelMelder() - with tempfile.TemporaryDirectory(prefix="af-gegenfall-") as wurzel: - ordner = Path(wurzel) - registry = Registry(db_pfad=ordner / "registry.db", artefakt_wurzel=ordner / "artifacts") - Killswitch.laden(broker=broker, registry=registry, config=ks_config) - agent, beschreibung = lade_agent(config, registry) - sitzung = PaperSitzung( - broker=broker, - kerzenquelle=quelle, - agent=agent, - agent_beschreibung=beschreibung_je_instrument(config, beschreibung), - agenten=agenten_je_instrument(config, agent), - registry=registry, - limits=limits, - killswitch_config=ks_config, - config=config, - uhr=uhr, - protokoll=protokoll, - protokoll_pfad="af-gegenfall.jsonl", - status_pfad=ordner / "status.json", - modus="probe", - soll_abbrechen=lambda: False, - melder=melder, - ) - code = sitzung.laufe() - gespeichert = registry.killswitch_zustand() - ereignisse = registry.killswitch_ereignisse() - - auto = [z for z in protokoll.zeilen if z.get("was") == "auto_fortsetzen"] - orders = [z for z in protokoll.zeilen if z.get("art") == "order"] - takte = [z for z in protokoll.zeilen if z.get("art") == "takt"] - print(f"Fall {fall}, {len(kerzen)} Kerzen ab {kerzen[0].zeit.isoformat()}, Beginn {beginn.isoformat()}") - print(f"Instrumente: {', '.join(config.alle_instrumente)}") - for zeile in auto: - print(f"auto_fortsetzen {zeile.get('ergebnis')} | {zeile.get('text')} | phase {zeile.get('phase')}") - for meldung in melder.gesehen: - print(f"Meldung [{meldung.prioritaet}] {meldung.titel}") - for zeile in meldung.text.split("\n"): - if zeile.startswith(("Killswitch", "Nicht selbst fortgesetzt", "Ausloeser", "Position")): - print(f" {zeile}") - print(f"Killswitch am Ende: {gespeichert.zustand}, Ausloeser {gespeichert.ausloeser}") - print(f"Killswitch-Ereignisse: {[(e.stufe, e.ausloeser) for e in ereignisse]}") - print(f"Takte {len(takte)}, Orders {len(orders)}, Exit {code}") - - befunde = [] - titel = [m.titel for m in melder.gesehen] - automatik = [e for e in ereignisse if e.ausloeser == "automatik"] - if fall == "flach": - if [z.get("ergebnis") for z in auto] != ["fortgesetzt"]: - befunde.append(f"auto_fortsetzen {[z.get('ergebnis') for z in auto]} statt ['fortgesetzt']") - if "Paper-Runner laeuft" not in titel or "Paper-Runner wartet" in titel: - befunde.append(f"Meldungen {titel}") - if len(automatik) != 1 or automatik[0].stufe != "fortsetzen": - befunde.append(f"Ereignisse mit Ausloeser automatik: {[(e.stufe, e.grund[:60]) for e in automatik]}") - if not orders: - befunde.append("keine Order -- fortgesetzt, aber nicht gehandelt") - if code != 0: - befunde.append(f"Exit {code} statt 0") - else: - if fall == "restposition": - erwartet = f"Konto nicht flach: {rest} {RESTPOSITION}" - else: - erwartet = "dieser Prozess ist nicht vor der Oeffnung gestartet" - if [z.get("text") for z in auto] != [f"nicht_fortgesetzt: {erwartet}"]: - befunde.append(f"auto_fortsetzen {[z.get('text') for z in auto]}") - wartet = [m for m in melder.gesehen if m.titel == "Paper-Runner wartet"] - if len(wartet) != 1 or f"Nicht selbst fortgesetzt: {erwartet}" not in wartet[0].text.split("\n"): - befunde.append(f"Meldungen {titel}") - if automatik: - befunde.append(f"{len(automatik)} Ereignis(se) mit Ausloeser automatik") - if orders: - befunde.append(f"{len(orders)} Order(s) ohne Fortsetzung") - if any(t.get("killswitch") != "angehalten" for t in takte): - befunde.append("ein Takt nicht angehalten") - erwarteter_code = 4 if fall == "restposition" else 0 - if code != erwarteter_code: - befunde.append(f"Exit {code} statt {erwarteter_code}") - for befund in befunde: - print(f"BEFUND: {befund}") - if befunde: - return 2 - print("ERGEBNIS: Fall wie erwartet") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/af_registerprobe.py b/.vault-tools/trading-plattform-pruefung/af_registerprobe.py deleted file mode 100644 index 125729c..0000000 --- a/.vault-tools/trading-plattform-pruefung/af_registerprobe.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Fragt pruefe_register (Paket AF) gegen ein Killswitch-Register, nur lesend. - -Koordinator-Werkzeug, nicht fuers Repo (2026-09-24, Befund der AF-Pruefung: das Drehbuch hatte fuer -die Registerpruefung nur eine Erwartung in Prosa). Oeffnet data/registry.db mit mode=ro -- die Klasse -Registry fuehrt beim Oeffnen das Schema aus und schriebe damit in die Datei --, baut Zustand und -Ereignisse als einfache Objekte und ruft die echte Funktion trading.paper.auto_fortsetzen.pruefe_register -des Projekts. Druckt den Befund und die juengsten Ereignisse (Stufe, Ausloeser, Zeit), nie einen Betrag. - -Was es nicht sieht: Konto, Spielgeld, Uhr und Schalter -- das pruefen Sitzung und vorstart_pruefung.py. -Vor der Oeffnung am Handelstag steht der Killswitch nach dem Dashboard-Neustart auf angehalten/neustart; -davor (etwa nach Pascals Klick am Vortag) ist "steht auf aktiv" der erwartete Befund, kein Fehler. - -Aufruf (WSL, im Projektordner): ~/venvs/trading/bin/python af_registerprobe.py -Exit 0: erfuellt, 1: nicht erfuellt, 3: Register fehlt. -""" - -import sqlite3 -import sys -from pathlib import Path -from types import SimpleNamespace - - -def main(argv=None) -> int: - argumente = sys.argv[1:] if argv is None else argv - projekt = Path(argumente[0] if argumente else ".").resolve() - db = projekt / "data" / "registry.db" - if not db.is_file(): - print(f"ABBRUCH: {db} fehlt") - return 3 - sys.path.insert(0, str(projekt)) - from trading.paper.auto_fortsetzen import pruefe_register - - verbindung = sqlite3.connect(f"file:{db}?mode=ro", uri=True) - try: - zeile = verbindung.execute("select zustand, ausloeser from killswitch_zustand").fetchone() - ereignisse = [ - SimpleNamespace(id=nummer, stufe=stufe, ausloeser=ausloeser, zeit=zeit) - for nummer, stufe, ausloeser, zeit in verbindung.execute( - "select id, stufe, ausloeser, zeit from killswitch_ereignisse order by id" - ) - ] - finally: - verbindung.close() - gespeichert = None if zeile is None else SimpleNamespace(zustand=zeile[0], ausloeser=zeile[1]) - befund = pruefe_register(gespeichert, ereignisse) - print(f"Zustand: {zeile}") - for ereignis in ereignisse[-4:]: - print(f" Nr. {ereignis.id}: {ereignis.stufe} von {ereignis.ausloeser}, {ereignis.zeit}") - print(f"pruefe_register: {'erfuellt' if befund.erfuellt else 'NICHT erfuellt'} -- {befund.grund}") - return 0 if befund.erfuellt else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/alpaca_formen_lesen.py b/.vault-tools/trading-plattform-pruefung/alpaca_formen_lesen.py deleted file mode 100644 index fdd5702..0000000 --- a/.vault-tools/trading-plattform-pruefung/alpaca_formen_lesen.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Liest die Antwortformen dreier Alpaca-Endpunkte fuer Paket M (zehn Aktien). Nur lesend, keine Order. - -Koordinator-Werkzeug, nicht Teil des Repos (entstanden 2026-09-24 nachts). Ausgegeben werden Schluessel, -Typen, Seitenmarken und die Handelsfelder der Anlagen (tradable, shortable, easy_to_borrow, fractionable, -marginable) -- keine Kontodaten, kein Schluessel. Endpunkte: - GET /v2/assets/{symbol} (Handels-API, paper-api) - GET /v2/stocks/trades/latest?symbols=... (Daten-API, feed=iex) - GET /v2/stocks/bars?symbols=...&timeframe=1Min (Daten-API, feed=iex, Seitenverhalten ueber Symbole) -Aufruf (WSL, TRADING_GEHEIMNISSE gesetzt): python alpaca_formen_lesen.py JJJJ-MM-TT SYMBOL [SYMBOL ...] -""" - -import sys -import tomllib -from pathlib import Path - -import requests - -sys.path.insert(0, str(Path.cwd())) - -from trading import geheimnisse # noqa: E402 - -HANDEL = "https://paper-api.alpaca.markets" -DATEN = "https://data.alpaca.markets" - - -def form(wert, tiefe=0): - if isinstance(wert, dict): - if tiefe > 1: - return "{...}" - return "{" + ", ".join(f"{k}: {form(v, tiefe + 1)}" for k, v in list(wert.items())[:14]) + "}" - if isinstance(wert, list): - return f"[{len(wert)} x {form(wert[0], tiefe + 1) if wert else '-'}]" - return type(wert).__name__ - - -def main() -> int: - tag, symbole = sys.argv[1], sys.argv[2:] - werte = tomllib.loads(geheimnisse.pfad().read_text(encoding="utf-8"))["alpaca"] - s = requests.Session() - s.trust_env = False - s.headers.update({"APCA-API-KEY-ID": werte["key_id"], "APCA-API-SECRET-KEY": werte["secret_key"]}) - - print("== /v2/assets/{symbol}") - for sym in symbole: - a = s.get(f"{HANDEL}/v2/assets/{sym}", timeout=20) - if a.status_code != 200: - print(sym, "HTTP", a.status_code) - continue - j = a.json() - felder = {k: j.get(k) for k in ("symbol", "exchange", "status", "tradable", "shortable", "easy_to_borrow", "fractionable", "marginable")} - print(felder) - print("Schluessel einer Anlage:", sorted(j.keys()) if symbole else "-") - - print("== /v2/stocks/trades/latest (gebuendelt)") - t = s.get(f"{DATEN}/v2/stocks/trades/latest", params={"symbols": ",".join(symbole), "feed": "iex"}, timeout=20) - print("HTTP", t.status_code, "Form:", form(t.json())) - trades = t.json().get("trades", {}) - print("Symbole mit Trade:", len(trades), "von", len(symbole), "; Felder eines Trades:", sorted(next(iter(trades.values())).keys()) if trades else "-") - - print("== /v2/stocks/bars (gebuendelt, Seiten)") - start, ende = f"{tag}T13:30:00Z", f"{tag}T20:00:00Z" - seiten, gesamt, je_symbol, token = 0, 0, {}, None - while True: - p = {"symbols": ",".join(symbole), "timeframe": "1Min", "start": start, "end": ende, "feed": "iex", "limit": 10000, "adjustment": "raw"} - if token: - p["page_token"] = token - b = s.get(f"{DATEN}/v2/stocks/bars", params=p, timeout=30) - if b.status_code != 200: - print("HTTP", b.status_code, b.text[:200]) - return 1 - j = b.json() - seiten += 1 - if seiten == 1: - print("Form erste Seite:", form(j)) - for sym, liste in (j.get("bars") or {}).items(): - je_symbol[sym] = je_symbol.get(sym, 0) + len(liste) - gesamt += len(liste) - token = j.get("next_page_token") - if not token or seiten > 20: - break - print("Seiten:", seiten, "Balken gesamt:", gesamt) - print("Balken je Symbol:", dict(sorted(je_symbol.items()))) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/angriffe.py b/.vault-tools/trading-plattform-pruefung/angriffe.py deleted file mode 100644 index 19cf0f3..0000000 --- a/.vault-tools/trading-plattform-pruefung/angriffe.py +++ /dev/null @@ -1,344 +0,0 @@ -"""Angriffsfaelle gegen ein laufendes Dashboard-Backend mit Anmeldung (Teil 10, Teil B). - -Koordinator-Pruefskript, nicht fuers Repo. Es beurteilt, ob ein LAUFENDES Backend die Zusagen der -Spec zu Teil 10 haelt; es ist ein Fangnetz, kein Nachweis. - -Geltungsbereich (Herkunft jeder Erwartung: Projektvorgabe, Spec Teil 10, Abschnitt "Teil B"): -- freie Pfade (/api/gesund, GET /api/sitzung), geschuetzte Pfade ohne Cookie 401, fremder Host 400, - schreibende Anfrage ohne oder mit fremdem Origin 403 (auch mit gueltigem Cookie), /docs /redoc - /openapi.json 404, Sicherheitskopfzeilen, einheitliche Ablehnung der Anmeldung, Cookie-Merkmale, - Wiederholung eines TOTP-Codes, Abmeldung entwertet das Cookie, WebSocket ohne Cookie und mit - fremdem Origin abgelehnt, Bremse (429 mit Retry-After, auch fuer die richtige Anmeldung), - Anmeldeprotokoll ohne Passwort und Code, /api/paper mit Protokollpfad ausserhalb des Ordners. -- seit 2026-09-18 abhaengig vom Schema der Basis-URL (Herkunft: Projektvorgabe, Spec Teil 10 Teil B - und trading/dashboard/sicherheit.py: secure=tls am Cookie, Strict-Transport-Security nur mit TLS): - unter https traegt das Sitzungscookie Secure und jede Antwort Strict-Transport-Security mit - positivem max-age, und eine Klartext-Anfrage an denselben Port liefert keine HTTP-Antwort 200; - unter http traegt das Cookie kein Secure und keine Antwort Strict-Transport-Security. -Was es nicht sieht: ob das Zertifikat taugt (es vertraut der mit --ca genannten Datei, bei einem -selbstsignierten Zertifikat also dem Zertifikat selbst), Protokollversionen und Chiffren von TLS, -TLS-Terminierung vor dem Dashboard (dann sieht die Anwendung HTTP und setzt weder Secure noch HSTS --- das meldet dieser Lauf unter https zu Recht als Befund), DNS-Rebinding im echten Browser, -Zeitverhalten der Passwortpruefung, alles hinter der Anmeldung ausser einem GET und einem POST je -Fall, und ob die CSP die gebaute Oberflaeche wirklich traegt (dafuer der Browser-Lauf). -Regressionsnachweis: gegen das alte Probe-Backend ohne Anmeldung (start_probe_backend.py) muss der -Lauf mit Exit 2 enden und die Schutzfaelle einzeln rot zeigen. Fuer die https-Faelle: gegen ein -TLS-Backend, dessen sicherheit.py weder Secure noch HSTS setzt (Mutant im Ziel), muessen genau -diese Faelle rot sein. - -Aufruf (in WSL, Venv des Projekts): - python angriffe.py [--ohne-bremse] [--ca ] ---ca nennt die Zertifikatsdatei, der bei https vertraut wird (selbstsigniertes Zertifikat). -Exit 0 ohne Befund, 2 mit Befund, 3 wenn eine Voraussetzung fehlt (auch das ist ein Befund). -""" - -import base64 -import hashlib -import hmac -import json -import os -import re -import struct -import sys -import time -from pathlib import Path - -import requests -from websockets.exceptions import InvalidStatus -from websockets.sync.client import connect - -befunde: list[str] = [] -bestanden: list[str] = [] - - -def pruefe(name: str, bedingung: bool, einzelheit: str = "") -> None: - if bedingung: - bestanden.append(name) - print(f" ok {name}") - else: - befunde.append(f"{name}: {einzelheit}") - print(f" ROT {name}: {einzelheit}") - - -def totp(geheimnis: str, zeit_s: float) -> str: - """Eigener Nachbau nach RFC 6238 / RFC 4226, unabhaengig vom Projektcode.""" - schluessel = base64.b32decode(geheimnis.upper() + "=" * (-len(geheimnis) % 8)) - zaehler = int(zeit_s // 30) - mac = hmac.new(schluessel, struct.pack(">Q", zaehler), hashlib.sha1).digest() - versatz = mac[-1] & 0x0F - zahl = struct.unpack(">I", mac[versatz : versatz + 4])[0] & 0x7FFFFFFF - return f"{zahl % 1_000_000:06d}" - - -def main() -> int: - if len(sys.argv) < 3: - print(__doc__) - return 3 - basis = sys.argv[1].rstrip("/") - ablage = Path(sys.argv[2]) - mit_bremse = "--ohne-bremse" not in sys.argv[3:] - host = basis.split("://", 1)[1] - origin = basis - tls = basis.startswith("https://") - - if "--ca" in sys.argv[3:]: - stelle = sys.argv.index("--ca") - if stelle + 1 >= len(sys.argv) or not Path(sys.argv[stelle + 1]).is_file(): - print("VORAUSSETZUNG FEHLT: --ca braucht eine vorhandene Zertifikatsdatei") - return 3 - # requests liest REQUESTS_CA_BUNDLE, der WebSocket-Client baut seinen Kontext ueber - # ssl.create_default_context(), das SSL_CERT_FILE liest. Pruefen statt abschalten. - os.environ["REQUESTS_CA_BUNDLE"] = sys.argv[stelle + 1] - os.environ["SSL_CERT_FILE"] = sys.argv[stelle + 1] - - zugang_json = ablage / "zugang.json" - if not zugang_json.is_file(): - print(f"VORAUSSETZUNG FEHLT: {zugang_json} (Ablage des Probe-Backends)") - return 3 - zugang = json.loads(zugang_json.read_text(encoding="utf-8")) - benutzer, passwort, geheimnis = zugang["benutzer"], zugang["passwort"], zugang["geheimnis"] - - # RFC-6238-Vektor gegen den eigenen Nachbau, bevor irgendetwas damit gemessen wird. - if totp("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", 59) != "287082": - print("VORAUSSETZUNG FEHLT: der eigene TOTP-Nachbau trifft den RFC-Vektor nicht") - return 3 - - try: - grund = requests.get(f"{basis}/api/gesund", timeout=5) - except requests.RequestException as ausnahme: - print(f"VORAUSSETZUNG FEHLT: Backend unter {basis} nicht erreichbar: {ausnahme!r}") - return 3 - - print("== Grundlinie") - pruefe("gesund ohne Cookie 200", grund.status_code == 200, f"HTTP {grund.status_code}") - sitzung = requests.get(f"{basis}/api/sitzung", timeout=5) - try: - sitzung_json = sitzung.json() - except ValueError: - sitzung_json = {} - pruefe( - "sitzung ohne Cookie: pflicht, eingerichtet, nicht angemeldet", - sitzung.status_code == 200 - and sitzung_json.get("pflicht") is True - and sitzung_json.get("eingerichtet") is True - and sitzung_json.get("angemeldet") is False, - f"HTTP {sitzung.status_code} {sitzung.text[:120]}", - ) - - print("== Geschuetzte Pfade ohne Cookie") - for pfad in ( - "/api/uebersicht", - "/api/agenten", - "/api/bestand", - "/api/laeufe", - "/api/markt/datensaetze", - "/api/paper", - ): - antwort = requests.get(f"{basis}{pfad}", timeout=10) - pruefe(f"GET {pfad} ohne Cookie 401", antwort.status_code == 401, f"HTTP {antwort.status_code}") - - print("== Fremder Host") - for pfad in ("/", "/api/gesund", "/api/uebersicht"): - antwort = requests.get(f"{basis}{pfad}", headers={"Host": "boese.example"}, timeout=5) - pruefe(f"GET {pfad} mit fremdem Host 400", antwort.status_code == 400, f"HTTP {antwort.status_code}") - - print("== Schnittstellenbeschreibung") - for pfad in ("/docs", "/redoc", "/openapi.json"): - antwort = requests.get(f"{basis}{pfad}", timeout=5) - pruefe(f"GET {pfad} 404", antwort.status_code == 404, f"HTTP {antwort.status_code}") - - print("== Kopfzeilen") - for pfad in ("/", "/api/gesund"): - kopf = requests.get(f"{basis}{pfad}", timeout=5).headers - csp = kopf.get("content-security-policy", "") - pruefe(f"{pfad}: CSP mit script-src 'self'", "script-src 'self'" in csp, csp[:80]) - pruefe(f"{pfad}: CSP mit frame-ancestors 'none'", "frame-ancestors 'none'" in csp, csp[:80]) - pruefe(f"{pfad}: X-Frame-Options DENY", kopf.get("x-frame-options") == "DENY", str(kopf.get("x-frame-options"))) - pruefe(f"{pfad}: nosniff", kopf.get("x-content-type-options") == "nosniff", str(kopf.get("x-content-type-options"))) - pruefe(f"{pfad}: Referrer-Policy", kopf.get("referrer-policy") == "no-referrer", str(kopf.get("referrer-policy"))) - pruefe(f"{pfad}: kein Server-Kopf", "server" not in kopf, str(kopf.get("server"))) - for pfad in ("/", "/api/gesund", "/api/uebersicht"): - hsts = requests.get(f"{basis}{pfad}", timeout=5).headers.get("strict-transport-security") - if tls: - treffer = re.fullmatch(r"max-age=(\d+)(;.*)?", hsts or "") - pruefe( - f"{pfad}: Strict-Transport-Security mit positivem max-age (HTTPS)", - treffer is not None and int(treffer.group(1)) > 0, - str(hsts), - ) - else: - pruefe(f"{pfad}: kein Strict-Transport-Security (HTTP)", hsts is None, str(hsts)) - if tls: - klartext = "http://" + host - try: - offen = requests.get(f"{klartext}/api/gesund", timeout=5) - ergebnis_klartext = f"HTTP {offen.status_code}" - except requests.RequestException as ausnahme: - ergebnis_klartext = f"keine Antwort: {type(ausnahme).__name__}" - pruefe("Klartext-Anfrage an den TLS-Port liefert kein 200", ergebnis_klartext != "HTTP 200", ergebnis_klartext) - api_kopf = requests.get(f"{basis}/api/gesund", timeout=5).headers - pruefe("/api/gesund: Cache-Control no-store", api_kopf.get("cache-control") == "no-store", str(api_kopf.get("cache-control"))) - - print("== Anmeldung: Origin und einheitliche Ablehnung") - rumpf = {"benutzer": benutzer, "passwort": passwort, "code": totp(geheimnis, time.time())} - ohne_origin = requests.post(f"{basis}/api/anmeldung", json=rumpf, timeout=10) - pruefe("Anmeldung ohne Origin 403", ohne_origin.status_code == 403, f"HTTP {ohne_origin.status_code}") - fremd = requests.post( - f"{basis}/api/anmeldung", json=rumpf, headers={"Origin": "http://boese.example"}, timeout=10 - ) - pruefe("Anmeldung mit fremdem Origin 403", fremd.status_code == 403, f"HTTP {fremd.status_code}") - - kopf = {"Origin": origin} - texte = set() - for name, daten in ( - ("falsches Passwort", {**rumpf, "passwort": "ganz-falsches-passwort"}), - ("falscher Code", {**rumpf, "code": "000000" if rumpf["code"] != "000000" else "111111"}), - ("unbekannter Benutzer", {**rumpf, "benutzer": "niemand"}), - ): - antwort = requests.post(f"{basis}/api/anmeldung", json=daten, headers=kopf, timeout=10) - pruefe(f"Anmeldung, {name}: 401", antwort.status_code == 401, f"HTTP {antwort.status_code}") - texte.add(antwort.text) - pruefe("alle drei Ablehnungen lauten gleich", len(texte) == 1, str(texte)[:200]) - - print("== Anmeldung: Erfolg, Cookie, Wiederholung") - jetzt = time.time() - code = totp(geheimnis, jetzt) - sitz = requests.Session() - gut = sitz.post(f"{basis}/api/anmeldung", json={**rumpf, "code": code}, headers=kopf, timeout=10) - pruefe("richtige Anmeldung 200", gut.status_code == 200, f"HTTP {gut.status_code} {gut.text[:120]}") - setzen = gut.headers.get("set-cookie", "") - klein = setzen.lower() - pruefe("Cookie tp_sitzung gesetzt", setzen.startswith("tp_sitzung="), setzen[:40]) - pruefe("Cookie HttpOnly", "httponly" in klein, setzen[-80:]) - pruefe("Cookie SameSite=Strict", "samesite=strict" in klein, setzen[-80:]) - pruefe("Cookie Path=/", "path=/" in klein, setzen[-80:]) - merkmale = [teil.strip() for teil in klein.split(";")] - if tls: - pruefe("Cookie mit Secure (HTTPS)", "secure" in merkmale, setzen[-80:]) - else: - pruefe("Cookie ohne Secure (HTTP)", "secure" not in merkmale, setzen[-80:]) - - wieder = requests.post(f"{basis}/api/anmeldung", json={**rumpf, "code": code}, headers=kopf, timeout=10) - pruefe("derselbe Code ein zweites Mal 401", wieder.status_code == 401, f"HTTP {wieder.status_code}") - - print("== Mit Cookie") - ueber = sitz.get(f"{basis}/api/uebersicht", timeout=15) - pruefe("GET /api/uebersicht mit Cookie 200", ueber.status_code == 200, f"HTTP {ueber.status_code}") - halt = sitz.post(f"{basis}/api/killswitch/anhalten", json={"grund": "angriffe.py"}, timeout=15) - pruefe("POST mit Cookie, ohne Origin 403", halt.status_code == 403, f"HTTP {halt.status_code}") - halt = sitz.post( - f"{basis}/api/killswitch/anhalten", - json={"grund": "angriffe.py"}, - headers={"Origin": "http://boese.example"}, - timeout=15, - ) - pruefe("POST mit Cookie, fremder Origin 403", halt.status_code == 403, f"HTTP {halt.status_code}") - halt = sitz.post( - f"{basis}/api/killswitch/anhalten", json={"grund": "angriffe.py"}, headers=kopf, timeout=15 - ) - pruefe( - "POST mit Cookie und Origin kommt an (200 oder 409)", - halt.status_code in (200, 409), - f"HTTP {halt.status_code} {halt.text[:120]}", - ) - - print("== /api/paper") - paper = sitz.get(f"{basis}/api/paper", timeout=10) - pruefe("GET /api/paper mit Cookie 200", paper.status_code == 200, f"HTTP {paper.status_code}") - status_datei = ablage / "status.json" - if status_datei.is_file() and paper.status_code == 200: - daten = paper.json() - pruefe("paper: Status und Zeilen geliefert", bool(daten.get("status")) and len(daten.get("zeilen", [])) > 0, str(daten)[:160]) - original = status_datei.read_text(encoding="utf-8") - verbogen = json.loads(original) - verbogen["protokoll"] = str(ablage / "registry.db") - status_datei.write_text(json.dumps(verbogen), encoding="utf-8") - try: - aussen = sitz.get(f"{basis}/api/paper", timeout=10).json() - pruefe( - "paper: Protokollpfad ausserhalb des Ordners liefert keine Zeilen, aber einen Hinweis", - aussen.get("zeilen") == [] and bool(aussen.get("hinweis")), - str(aussen)[:200], - ) - finally: - status_datei.write_text(original, encoding="utf-8") - else: - print(" (kein Paper-Status in der Ablage; Fall uebersprungen — Backend mit 'mit-paperstatus' starten)") - - print("== WebSocket") - ws_url = basis.replace("http", "ws", 1) + "/ws/uebersicht" - cookie = "; ".join(f"{c.name}={c.value}" for c in sitz.cookies) - - def ws_versuch(kopfzeilen: dict[str, str], mit_origin: str | None) -> str: - try: - with connect(ws_url, additional_headers=kopfzeilen, origin=mit_origin, open_timeout=5) as ws: - return "offen:" + str(ws.recv(timeout=10))[:40] - except InvalidStatus as ausnahme: - return f"abgelehnt:{ausnahme.response.status_code}" - except Exception as ausnahme: # noqa: BLE001 - jede andere Ablehnung ist auch eine - return f"abgelehnt:{ausnahme!r}"[:80] - - pruefe("WebSocket ohne Cookie abgelehnt", ws_versuch({}, origin).startswith("abgelehnt"), ws_versuch({}, origin)) - ergebnis = ws_versuch({"Cookie": cookie}, "http://boese.example") - pruefe("WebSocket mit Cookie, fremder Origin abgelehnt", ergebnis.startswith("abgelehnt"), ergebnis) - ergebnis = ws_versuch({"Cookie": cookie}, origin) - pruefe("WebSocket mit Cookie und Origin liefert einen Snapshot", ergebnis.startswith("offen:"), ergebnis) - - print("== Abmeldung") - ab = sitz.post(f"{basis}/api/abmeldung", headers=kopf, timeout=10) - pruefe("Abmeldung 204", ab.status_code == 204, f"HTTP {ab.status_code}") - danach = requests.get(f"{basis}/api/uebersicht", headers={"Cookie": cookie}, timeout=10) - pruefe("altes Cookie nach der Abmeldung 401", danach.status_code == 401, f"HTTP {danach.status_code}") - - if mit_bremse: - print("== Bremse (sperrt diese Adresse; deshalb zuletzt)") - status = [] - for _ in range(12): - antwort = requests.post( - f"{basis}/api/anmeldung", - json={**rumpf, "passwort": "ganz-falsches-passwort"}, - headers=kopf, - timeout=10, - ) - status.append(antwort.status_code) - if antwort.status_code == 429: - pruefe("429 traegt Retry-After", antwort.headers.get("retry-after", "").isdigit(), str(antwort.headers.get("retry-after"))) - break - pruefe("Bremse greift innerhalb von zwoelf Fehlversuchen", 429 in status, str(status)) - # Ein frischer Zeitschritt, damit die Ablehnung nicht an der Wiederholung des Codes liegt. - warten = 30 - (time.time() % 30) + 1 - time.sleep(warten) - richtig = requests.post( - f"{basis}/api/anmeldung", - json={**rumpf, "code": totp(geheimnis, time.time())}, - headers=kopf, - timeout=10, - ) - pruefe("gesperrt gilt auch fuer die richtige Anmeldung (429)", richtig.status_code == 429, f"HTTP {richtig.status_code}") - - print("== Anmeldeprotokoll") - protokoll = ablage / "anmeldungen.jsonl" - if not protokoll.is_file(): - pruefe("Anmeldeprotokoll vorhanden", False, str(protokoll)) - else: - text = protokoll.read_text(encoding="utf-8") - pruefe("Protokoll traegt das Passwort nicht", passwort not in text and "ganz-falsches-passwort" not in text, "Passwort im Protokoll") - pruefe("Protokoll traegt den Code nicht", code not in text, "Code im Protokoll") - ergebnisse = {json.loads(z).get("ergebnis") for z in text.splitlines() if z.strip()} - pruefe("Protokoll kennt ok und falsch", {"ok", "falsch"} <= ergebnisse, str(ergebnisse)) - - print() - print(f"{len(bestanden)} Faelle ok, {len(befunde)} Befund(e).") - for befund in befunde: - print(f"BEFUND: {befund}") - return 2 if befunde else 0 - - -if __name__ == "__main__": - try: - sys.exit(main()) - except Exception as ausnahme: # noqa: BLE001 - ein Absturz des Pruefskripts ist selbst ein Befund - for befund in befunde: - print(f"BEFUND: {befund}") - print(f"BEFUND: das Pruefskript ist abgebrochen: {ausnahme!r}") - sys.exit(2) diff --git a/.vault-tools/trading-plattform-pruefung/bremse_echtlauf.py b/.vault-tools/trading-plattform-pruefung/bremse_echtlauf.py deleted file mode 100644 index 52cd41e..0000000 --- a/.vault-tools/trading-plattform-pruefung/bremse_echtlauf.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Gegenmessung zu Task 229: Protokollwachstum im gesperrten Zustand, beide Haelften des Schluesselwegs. - -Aufruf (WSL, Venv): bremse_echtlauf.py - beginn -- fuenf falsche Passwoerter am Schluesselweg, dann 5 s Dauerfeuer auf beginn, dann 5 s auf - abschluss, dann 5 s auf den Code-Weg. Erwartet: genau EINE Zeile "gesperrt". - abschluss -- fuenf falsche Anmeldungen am Code-Weg, dann 5 s Dauerfeuer auf abschluss. Erwartet: genau - eine Zeile "gesperrt" mit weg=schluessel und leerem Benutzer. -Die Probe-Instanz (start_probe_w.py) laeuft aus einer Kopie oder einem Arbeitsbaum ohne Geheimnisdatei, mit -eigener Ablage und eigenem Port. Die Bremse lebt im Prozess: je Durchgang eine frische Instanz starten. -""" - -import http.client -import json -import sys -import time -from pathlib import Path - -PORT = int(sys.argv[1]) -ORDNER = Path(sys.argv[2]) -ART = sys.argv[3] -HOST = "localhost:" + str(PORT) -ORIGIN = "http://localhost:" + str(PORT) -PROTOKOLL = ORDNER / "anmeldungen.jsonl" -daten = json.loads((ORDNER / "zugang.json").read_text(encoding="utf-8")) -befunde = 0 - - -def sage(name, ok, text): - global befunde - if not ok: - befunde += 1 - print("[" + ("OK " if ok else "FAIL") + "] " + name + ": " + str(text), flush=True) - - -def anfrage(pfad, koerper, host=HOST, origin=ORIGIN): - verbindung = http.client.HTTPConnection("127.0.0.1", PORT, timeout=30) - rumpf = json.dumps(koerper).encode("utf-8") - verbindung.request( - "POST", pfad, body=rumpf, - headers={"Host": host, "Origin": origin, "Content-Type": "application/json"}, - ) - antwort = verbindung.getresponse() - inhalt = antwort.read() - verbindung.close() - return antwort.status, inhalt - - -def zeilen(): - if not PROTOKOLL.exists(): - return [] - return [json.loads(z) for z in PROTOKOLL.read_text(encoding="utf-8").splitlines() if z.strip()] - - -def groesse(): - return PROTOKOLL.stat().st_size if PROTOKOLL.exists() else 0 - - -def feuer(name, pfad, koerper, sekunden=5.0): - vorher_b, vorher_z = groesse(), len(zeilen()) - ende = time.monotonic() + sekunden - stati = {} - anzahl = 0 - while time.monotonic() < ende: - status, _ = anfrage(pfad, koerper) - stati[status] = stati.get(status, 0) + 1 - anzahl += 1 - zuwachs_b, zuwachs_z = groesse() - vorher_b, len(zeilen()) - vorher_z - print( - f" {name}: {anzahl} Anfragen in {sekunden:.1f} s, Stati {stati}, Zuwachs {zuwachs_b} B " - f"({zuwachs_b / sekunden:.0f} B/s), {zuwachs_z} Zeile(n)", - flush=True, - ) - return anzahl, stati, zuwachs_b, zuwachs_z - - -BEGINN = "/api/anmeldung/schluessel/beginn" -ABSCHLUSS = "/api/anmeldung/schluessel/abschluss" -CODE = "/api/anmeldung" -falsch_beginn = {"benutzer": daten["benutzer"], "passwort": "falsch"} -falsch_code = {"benutzer": daten["benutzer"], "passwort": "falsch", "code": "000000"} -erfunden = {"vorgang": "A" * 43, "antwort": {}} - -if ART == "beginn": - stati = [anfrage(BEGINN, falsch_beginn)[0] for _ in range(5)] - sage("M1 fuenf falsche Passwoerter am Schluesselweg", stati == [401] * 5, stati) - n, st, zb, zz = feuer("beginn im gesperrten Zustand", BEGINN, falsch_beginn) - sage("M2 beginn: alle 429, hoechstens eine Zeile", set(st) == {429} and zz <= 1, f"{zz} Zeile(n), {zb} B") - n, st, zb, zz = feuer("abschluss im gesperrten Zustand", ABSCHLUSS, erfunden) - sage("M3 abschluss: alle 429, keine weitere Zeile", set(st) == {429} and zz == 0, f"{zz} Zeile(n), {zb} B") - n, st, zb, zz = feuer("Code-Weg im gesperrten Zustand", CODE, falsch_code) - sage("M4 Code-Weg: alle 429, keine weitere Zeile", set(st) == {429} and zz == 0, f"{zz} Zeile(n), {zb} B") - alle = zeilen() - gesperrt = [z for z in alle if z["ergebnis"] == "gesperrt"] - sage( - "M5 genau eine Zeile gesperrt, vom Schluesselweg", - len(gesperrt) == 1 and gesperrt[0].get("weg") == "schluessel" and gesperrt[0]["benutzer"] == daten["benutzer"], - f"{len(gesperrt)} von {len(alle)} Zeilen: {gesperrt[:2]}", - ) - status, inhalt = anfrage(ABSCHLUSS, erfunden, host="127.0.0.1:" + str(PORT), origin="http://127.0.0.1:" + str(PORT)) - sage("M6 abschluss unter 127.0.0.1 ist 409 mit der richtigen Adresse", status == 409 and ORIGIN.encode() in inhalt, f"{status} {inhalt[:120]!r}") -else: - stati = [anfrage(CODE, falsch_code)[0] for _ in range(5)] - sage("N1 fuenf falsche Anmeldungen am Code-Weg", stati == [401] * 5, stati) - n, st, zb, zz = feuer("abschluss als erste gesperrte Anfrage", ABSCHLUSS, erfunden) - sage("N2 abschluss: alle 429, genau eine Zeile", set(st) == {429} and zz == 1, f"{zz} Zeile(n), {zb} B") - gesperrt = [z for z in zeilen() if z["ergebnis"] == "gesperrt"] - sage( - "N3 die eine Zeile traegt weg=schluessel und keinen Benutzer", - len(gesperrt) == 1 and gesperrt[0].get("weg") == "schluessel" and gesperrt[0]["benutzer"] == "", - gesperrt[:2], - ) - n, st, zb, zz = feuer("beginn danach", BEGINN, falsch_beginn, sekunden=2.0) - sage("N4 beginn danach: keine weitere Zeile", set(st) == {429} and zz == 0, f"{zz} Zeile(n)") - -text = PROTOKOLL.read_text(encoding="utf-8") if PROTOKOLL.exists() else "" -sage("Z1 kein Passwort im Protokoll", daten["passwort"] not in text and "falsch" not in text.replace('"ergebnis": "falsch"', ""), "geprueft") -print(f"{befunde} Befund(e).") -sys.exit(1 if befunde else 0) diff --git a/.vault-tools/trading-plattform-pruefung/browser_r.py b/.vault-tools/trading-plattform-pruefung/browser_r.py deleted file mode 100644 index 1146795..0000000 --- a/.vault-tools/trading-plattform-pruefung/browser_r.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Browserlauf zu Paket R: der Red Button mit virtuellem Sicherheitsschluessel gegen eine Probe-Instanz. - -Koordinator-Pruefcode, nicht Teil des Repos. Laeuft unter Windows mit Playwright (Chromium) und dem -virtuellen Authenticator des DevTools-Protokolls, wie browser_w.py. Ablauf: mit Passwort und Code -anmelden, in "Zugang" einen Schluessel registrieren, zurueck zur Uebersicht, den Red Button druecken, -Grund eintragen, Stick (virtuell) beruehren -- danach muss der Killswitch gesperrt sein, im Register -genau ein Ereignis "glattstellen" mit Ausloeser "pascal" stehen und der Zaehler des Authenticators -gestiegen sein. Dazu: kein sichtbares "Glattstellen" mehr, kein Wortfeld mit Schluessel, unter -127.0.0.1 der Satz "nur unter" ohne POST, und ein Screenshot des Dialogs. - -Was der Lauf nicht misst: eine abgelehnte Signatur (der virtuelle Authenticator signiert immer gueltig), -das Bestaetigungswort ohne Schluessel, die Protokollbremse. Das deckt tests/test_dashboard_red_button.py. - -Aufruf: python -X utf8 browser_r.py -""" - -import json -import sqlite3 -import sys -from pathlib import Path - -from playwright.sync_api import sync_playwright - -HIER = Path(__file__).resolve().parent -sys.path.insert(0, str(HIER)) -import browser_w # noqa: E402 -- totp, schritt, melde_mit_code_an, ZUGANG, PORT - -PORT = int(sys.argv[1]) -ORDNER = Path(sys.argv[2]) -BILDER = Path(sys.argv[3]) -BILDER.mkdir(parents=True, exist_ok=True) -schritt = browser_w.schritt -ZUGANG = browser_w.ZUGANG - - -def ereignisse() -> list[tuple]: - db = ORDNER / "registry.db" - with sqlite3.connect(f"file:{db}?mode=ro", uri=True) as verbindung: - spalten = [z[1] for z in verbindung.execute("PRAGMA table_info(killswitch_ereignisse)")] - zeilen = verbindung.execute("SELECT * FROM killswitch_ereignisse ORDER BY rowid").fetchall() - return [dict(zip(spalten, z)) for z in zeilen] - - -def freigeben_falls_gesperrt(seite) -> None: - """Der Red Button ist nur im Zustand aktiv oder angehalten bedienbar; nach einem Notaus erst freigeben.""" - if "Killswitch gesperrt" in seite.inner_text("body"): - seite.get_by_role("button", name="Freigeben").click() - # Nach dem Freigeben ist der Zustand aktiv (oder angehalten) -- massgeblich ist, dass der - # Red Button wieder bedienbar ist; auf den Text eines bestimmten Zustands zu warten, war falsch. - knopf = seite.get_by_role("button", name="Red Button") - for _ in range(30): - if knopf.is_enabled(): - break - seite.wait_for_timeout(500) - else: - raise AssertionError("Red Button nach dem Freigeben nicht bedienbar") - - -def main() -> int: - try: - return _lauf() - finally: - print("\n".join(browser_w.schritte)) - print(f"\n{len(browser_w.schritte) - len(browser_w.befunde)} von {len(browser_w.schritte)} ok; Befunde: {len(browser_w.befunde)}") - print(f"Screenshots unter {BILDER}") - - -def _lauf() -> int: - with sync_playwright() as p: - browser = p.chromium.launch(headless=True) - kontext = browser.new_context(viewport={"width": 1280, "height": 900}) - seite = kontext.new_page() - konsole: list[str] = [] - api: list[str] = [] - seite.on("console", lambda m: konsole.append(f"{m.type}: {m.text}") if m.type in ("error", "warning") else None) - seite.on("pageerror", lambda e: konsole.append(f"pageerror: {e}")) - seite.on("response", lambda r: api.append(f"{r.status} {r.request.method} {r.url.split(str(PORT), 1)[-1]}") if "/api/" in r.url else None) - - cdp = kontext.new_cdp_session(seite) - cdp.send("WebAuthn.enable") - authenticator = cdp.send("WebAuthn.addVirtualAuthenticator", {"options": { - "protocol": "ctap2", "transport": "usb", "hasResidentKey": False, - "hasUserVerification": True, "isUserVerified": True, "automaticPresenceSimulation": True, - }})["authenticatorId"] - - def zugaenge() -> list[dict]: - return cdp.send("WebAuthn.getCredentials", {"authenticatorId": authenticator})["credentials"] - - seite.goto(f"http://localhost:{PORT}/") - seite.locator("#anmeldung-benutzer").wait_for(timeout=15000) - zaehler = browser_w.melde_mit_code_an(seite, -1) - schritt("Anmeldung mit Passwort und Code", True) - - # Vor der Registrierung: der Knopf heisst Red Button, "Glattstellen" steht nirgends sichtbar. - knopf = seite.get_by_role("button", name="Red Button") - schritt("Der Knopf in der Kopfzeile heisst Red Button", knopf.count() == 1) - schritt("Kein sichtbares 'Glattstellen' auf der Seite", "Glattstellen" not in seite.inner_text("body")) - - # Schluessel registrieren (wie in browser_w.py). - seite.get_by_text("Zugang", exact=True).first.click() - seite.locator("#zugang-bezeichnung").wait_for(timeout=10000) - seite.fill("#zugang-bezeichnung", "Virtueller Schluessel") - seite.fill("#zugang-passwort", ZUGANG["passwort"]) - seite.get_by_role("button", name="Schlüssel hinzufügen").click() - liste = seite.get_by_role("list", name="Sicherheitsschlüssel") - liste.wait_for(timeout=15000) - schritt("Schluessel registriert", "Virtueller Schluessel" in liste.inner_text()) - vorher = zugaenge() - zaehler_vorher = vorher[0]["signCount"] if vorher else -1 - - # Erst die Gegenprobe unter 127.0.0.1, solange der Killswitch noch angehalten (bedienbar) ist: - # der Satz "nur unter", kein Wortfeld, kein POST an den Red Button. - zweite = kontext.new_page() - zwei_api: list[str] = [] - zweite.on("response", lambda r: zwei_api.append(f"{r.status} {r.request.method} {r.url.split(str(PORT), 1)[-1]}") if "/api/killswitch" in r.url else None) - zweite.goto(f"http://127.0.0.1:{PORT}/") - zweite.locator("#anmeldung-benutzer").wait_for(timeout=15000) - zaehler = browser_w.melde_mit_code_an(zweite, zaehler) - zweite.get_by_role("button", name="Red Button").click() - zweite.get_by_label("Grund").wait_for(timeout=10000) - zweite.wait_for_timeout(1000) - text2 = zweite.inner_text("body") - schritt("Unter 127.0.0.1: Hinweis 'nur unter' mit der richtigen Adresse", "nur unter" in text2 and f"localhost:{PORT}" in text2) - # Der Knopf "Bestaetigen" (aria-label Ausfuehren) bleibt im Dialog stehen, ist aber gesperrt - # (bereit ist ohne Weg falsch) -- gemessen am 2026-09-22; ein fehlender Knopf war zu streng verlangt. - ausfuehren = zweite.get_by_label("Ausfuehren") - schritt("Unter 127.0.0.1: kein Wortfeld, und der Knopf ist gesperrt", - zweite.get_by_label("Bestaetigungswort").count() == 0 - and (ausfuehren.count() == 0 or not ausfuehren.is_enabled())) - schritt(f"Unter 127.0.0.1: kein POST an den Red Button ({zwei_api})", not any("POST" in a and "glattstellen" in a for a in zwei_api)) - zweite.screenshot(path=str(BILDER / "red-button-127.png")) - zweite.close() - - seite.get_by_text("Übersicht", exact=True).first.click() - seite.get_by_role("button", name="Red Button").wait_for(timeout=10000) - vorher_ereignisse = [e for e in ereignisse() if e.get("stufe") == "glattstellen"] - api.clear() - - seite.get_by_role("button", name="Red Button").click() - seite.get_by_label("Grund").wait_for(timeout=10000) - text = seite.inner_text("body") - schritt("Der Dialog nennt den Stick und kein Wortfeld", "Stick" in text and seite.get_by_label("Bestaetigungswort").count() == 0) - schritt("Der Dialog sagt, was passiert (Paper-Konto)", "Paper-Konto" in text) - seite.fill("input[aria-label='Grund']", "Notaus im Browserlauf") - seite.screenshot(path=str(BILDER / "red-button-dialog.png")) - seite.get_by_label("Ausfuehren").click() - - # Der Dialog schliesst sich nach der Freigabe; der Zustand wird gesperrt. - seite.get_by_label("Grund").wait_for(state="hidden", timeout=20000) - seite.wait_for_timeout(1500) - aufrufe = [a for a in api if "/api/killswitch/glattstellen" in a] - schritt(f"Zwei Aufrufe: beginn und Abschluss mit 200 ({aufrufe})", - any(a.startswith("200 POST") and a.endswith("/api/killswitch/glattstellen/beginn") for a in aufrufe) - and any(a.startswith("200 POST") and a.endswith("/api/killswitch/glattstellen") for a in aufrufe)) - nachher = zugaenge() - zaehler_nachher = nachher[0]["signCount"] if nachher else -1 - schritt(f"Der Authenticator wurde gefragt (Zaehler {zaehler_vorher} -> {zaehler_nachher})", zaehler_nachher > zaehler_vorher) - neu = [e for e in ereignisse() if e.get("stufe") == "glattstellen"] - schritt(f"Genau ein neues Registerereignis glattstellen ({len(neu) - len(vorher_ereignisse)})", len(neu) - len(vorher_ereignisse) == 1) - if neu: - letztes = neu[-1] - schritt(f"Ausloeser pascal und der Grund im Register ({letztes.get('ausloeser')!r})", - letztes.get("ausloeser") == "pascal" and "Browserlauf" in str(letztes.get("grund"))) - schritt("Kopfzeile zeigt gesperrt", "gesperrt" in seite.inner_text("body")) - seite.screenshot(path=str(BILDER / "red-button-danach.png")) - - # Das Anmeldeprotokoll: keine Zeile mit weg red-button (nichts wurde abgelehnt), das Wort nirgends. - protokoll = ORDNER / "anmeldungen.jsonl" - if protokoll.exists(): - zeilen = protokoll.read_text(encoding="utf-8").splitlines() - schritt("Keine abgelehnte Freigabe im Anmeldeprotokoll", not any('"red-button"' in z for z in zeilen)) - - schritt(f"Keine Konsolenfehler ({konsole[:3]})", not konsole) - browser.close() - - return 2 if browser_w.befunde else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/browser_w.py b/.vault-tools/trading-plattform-pruefung/browser_w.py deleted file mode 100644 index 6e71def..0000000 --- a/.vault-tools/trading-plattform-pruefung/browser_w.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Browserlauf zu Paket W: echter Chromium mit virtuellem Authenticator gegen die Probe-Instanz. - -Koordinator-Pruefcode. Laeuft unter Windows mit Playwright. Ablauf: mit Passwort und Code anmelden, in -"Zugang" einen Schluessel registrieren, abmelden, mit dem Schluessel anmelden (der Zaehler des virtuellen -Authenticators muss steigen -- sonst waere er nie gefragt worden), abmelden, wieder mit Code anmelden, -Schluessel entfernen. Dazu die Gegenprobe unter 127.0.0.1 und ein Blick in Schluesseldatei und -Anmeldeprotokoll der Probe-Instanz. - -Aufruf: python browser_w.py -""" - -import base64 -import hashlib -import hmac -import json -import struct -import sys -import time -from pathlib import Path - -from playwright.sync_api import sync_playwright - -PORT = int(sys.argv[1]) -ORDNER = Path(sys.argv[2]) -ZUGANG = json.loads((ORDNER / "zugang.json").read_text(encoding="utf-8")) -KNOPF_SCHLUESSEL = "Mit Sicherheitsschlüssel anmelden" - -befunde: list[str] = [] -schritte: list[str] = [] - - -def totp(geheimnis: str) -> tuple[str, int]: - schluessel = base64.b32decode(geheimnis) - zaehler = int(time.time() // 30) - mac = hmac.new(schluessel, struct.pack(">Q", zaehler), hashlib.sha1).digest() - versatz = mac[-1] & 0x0F - wert = (struct.unpack(">I", mac[versatz:versatz + 4])[0] & 0x7FFFFFFF) % 1_000_000 - return f"{wert:06d}", zaehler - - -def schritt(text: str, ok: bool) -> None: - schritte.append(("OK " if ok else "BEFUND ") + text) - if not ok: - befunde.append(text) - - -def melde_mit_code_an(seite, letzter_zaehler: int) -> int: - # Ein Code gilt je Zeitschritt einmal; vor der zweiten Anmeldung den naechsten Schritt abwarten. - while int(time.time() // 30) <= letzter_zaehler: - time.sleep(1.0) - code, zaehler = totp(ZUGANG["geheimnis"]) - seite.fill("#anmeldung-benutzer", ZUGANG["benutzer"]) - seite.fill("#anmeldung-passwort", ZUGANG["passwort"]) - seite.fill("#anmeldung-code", code) - seite.get_by_role("button", name="Anmelden", exact=True).click() - seite.get_by_role("button", name="Abmelden").wait_for(timeout=15000) - return zaehler - - -def main() -> int: - with sync_playwright() as p: - browser = p.chromium.launch(headless=True) - kontext = browser.new_context() - seite = kontext.new_page() - konsole: list[str] = [] - api: list[str] = [] - seite.on("console", lambda m: konsole.append(f"{m.type}: {m.text}") if m.type in ("error", "warning") else None) - seite.on("pageerror", lambda e: konsole.append(f"pageerror: {e}")) - seite.on("response", lambda r: api.append(f"{r.status} {r.request.method} {r.url.split(str(PORT), 1)[-1]}") if "/api/" in r.url else None) - - cdp = kontext.new_cdp_session(seite) - cdp.send("WebAuthn.enable") - authenticator = cdp.send("WebAuthn.addVirtualAuthenticator", {"options": { - "protocol": "ctap2", "transport": "usb", "hasResidentKey": False, - "hasUserVerification": True, "isUserVerified": True, "automaticPresenceSimulation": True, - }})["authenticatorId"] - - def zugaenge() -> list[dict]: - return cdp.send("WebAuthn.getCredentials", {"authenticatorId": authenticator})["credentials"] - - seite.goto(f"http://localhost:{PORT}/") - seite.locator("#anmeldung-benutzer").wait_for(timeout=15000) - schritt("Anmeldemaske unter http://localhost erreichbar", True) - knopf = seite.get_by_role("button", name=KNOPF_SCHLUESSEL) - schritt("Knopf fuer den Schluessel ist da und bedienbar", knopf.count() == 1 and knopf.is_enabled()) - - zaehler = melde_mit_code_an(seite, -1) - schritt("Anmeldung mit Passwort und Code", True) - - seite.get_by_text("Zugang", exact=True).first.click() - seite.locator("#zugang-bezeichnung").wait_for(timeout=10000) - seite.fill("#zugang-bezeichnung", "Virtueller Schluessel") - seite.fill("#zugang-passwort", ZUGANG["passwort"]) - seite.get_by_role("button", name="Schlüssel hinzufügen").click() - liste = seite.get_by_role("list", name="Sicherheitsschlüssel") - liste.wait_for(timeout=15000) - schritt("Registrierung: der Schluessel steht in der Liste", "Virtueller Schluessel" in liste.inner_text()) - vorher = zugaenge() - schritt("Der virtuelle Authenticator haelt genau einen Zugang", len(vorher) == 1) - zaehler_vorher = vorher[0]["signCount"] if vorher else -1 - - # Schluesseldatei ansehen, solange der Schluessel hinterlegt ist. - datei = ORDNER / "zugang-schluessel.json" - kandidaten = [pfad for pfad in ORDNER.glob("*schluessel*")] - schritt(f"Schluesseldatei liegt neben der Zugangsdatei ({[k.name for k in kandidaten]})", bool(kandidaten)) - if kandidaten: - inhalt = kandidaten[0].read_text(encoding="utf-8") - schritt("Schluesseldatei traegt weder Passwort noch TOTP-Geheimnis", - ZUGANG["passwort"] not in inhalt and ZUGANG["geheimnis"] not in inhalt) - daten = json.loads(inhalt) - print("Schluesseldatei, Form:", json.dumps( - {k: (sorted(v[0]) if isinstance(v, list) and v and isinstance(v[0], dict) else type(v).__name__) - for k, v in (daten.items() if isinstance(daten, dict) else {"liste": daten}.items())}, - ensure_ascii=False)[:600]) - - seite.get_by_role("button", name="Abmelden").click() - seite.locator("#anmeldung-benutzer").wait_for(timeout=10000) - seite.fill("#anmeldung-benutzer", ZUGANG["benutzer"]) - seite.fill("#anmeldung-passwort", ZUGANG["passwort"]) - seite.get_by_role("button", name=KNOPF_SCHLUESSEL).click() - seite.get_by_role("button", name="Abmelden").wait_for(timeout=15000) - schritt("Anmeldung mit Passwort und Sicherheitsschluessel, ohne Code", True) - nachher = zugaenge() - zaehler_nachher = nachher[0]["signCount"] if nachher else -1 - schritt(f"Der Authenticator wurde wirklich gefragt (Zaehler {zaehler_vorher} -> {zaehler_nachher})", - zaehler_nachher > zaehler_vorher) - - # Falsches Passwort auf dem Schluessel-Weg: Ablehnung, keine Sitzung. - seite.get_by_role("button", name="Abmelden").click() - seite.locator("#anmeldung-benutzer").wait_for(timeout=10000) - seite.fill("#anmeldung-benutzer", ZUGANG["benutzer"]) - seite.fill("#anmeldung-passwort", "ganz-sicher-falsch-2026") - seite.get_by_role("button", name=KNOPF_SCHLUESSEL).click() - hinweis = seite.get_by_role("alert") - hinweis.wait_for(timeout=10000) - schritt("Falsches Passwort auf dem Schluessel-Weg wird abgelehnt", "stimmt nicht" in hinweis.inner_text()) - - zaehler = melde_mit_code_an(seite, zaehler) - schritt("Der Code-Weg geht danach unveraendert", True) - - seite.get_by_text("Zugang", exact=True).first.click() - seite.locator("#zugang-passwort").wait_for(timeout=10000) - seite.fill("#zugang-passwort", ZUGANG["passwort"]) - seite.get_by_role("button", name="Entfernen").click() - seite.get_by_text("Es ist kein Sicherheitsschlüssel hinterlegt").wait_for(timeout=10000) - schritt("Entfernen: die Liste ist wieder leer", True) - - # Gegenprobe unter 127.0.0.1: beginn antwortet mit 409, kein 500, kein leerer Schirm. - zweite = kontext.new_page() - zweite.goto(f"http://127.0.0.1:{PORT}/") - zweite.locator("#anmeldung-benutzer").wait_for(timeout=15000) - antwort = zweite.evaluate( - """async ([benutzer, passwort]) => { - const r = await fetch('/api/anmeldung/schluessel/beginn', {method: 'POST', - headers: {'content-type': 'application/json'}, - body: JSON.stringify({benutzer, passwort})}); - return [r.status, await r.text()]; - }""", [ZUGANG["benutzer"], ZUGANG["passwort"]]) - schritt(f"Unter 127.0.0.1 antwortet beginn mit 409 ({antwort[0]})", antwort[0] == 409) - print("Satz unter 127.0.0.1:", antwort[1][:260]) - browser.close() - - protokoll = ORDNER / "anmeldungen.jsonl" - zeilen = [json.loads(z) for z in protokoll.read_text(encoding="utf-8").splitlines() if z.strip()] - wege = [z.get("weg") for z in zeilen] - print(f"Anmeldeprotokoll: {len(zeilen)} Zeilen, Ausgaenge {[z.get('ausgang') for z in zeilen]}, Wege {wege}") - roh = protokoll.read_text(encoding="utf-8") - schritt("Anmeldeprotokoll traegt eine Zeile mit weg=schluessel", "schluessel" in wege) - schritt("Anmeldeprotokoll traegt weder Passwort noch Geheimnis", - ZUGANG["passwort"] not in roh and ZUGANG["geheimnis"] not in roh and "ganz-sicher-falsch" not in roh) - - boese = [z for z in konsole if "Content Security Policy" in z or "Permissions" in z or "pageerror" in z] - schritt(f"Keine CSP-, Permissions-Policy- oder Seitenfehler in der Konsole ({len(boese)})", not boese) - fuenfhunderter = [z for z in api if z.startswith("5")] - schritt(f"Keine 5xx-Antwort der Schnittstelle ({len(fuenfhunderter)})", not fuenfhunderter) - - print("--- Schritte") - for zeile in schritte: - print(zeile) - print("--- API-Antworten der Schluessel-Wege") - for zeile in api: - if "schluessel" in zeile or "anmeldung" in zeile: - print(" " + zeile) - print("--- Konsole (Fehler und Warnungen)") - for zeile in konsole[:12]: - print(" " + zeile[:220]) - print(f"{len(befunde)} Befund(e): {befunde}") - return 2 if befunde else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/browserlauf.py b/.vault-tools/trading-plattform-pruefung/browserlauf.py deleted file mode 100644 index ceeb841..0000000 --- a/.vault-tools/trading-plattform-pruefung/browserlauf.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Browser-Lauf zu Teil 3b mit Playwright (Windows-Python), gegen ein laufendes Backend. - -Koordinator-Pruefskript, nicht fuers Repo. Aufruf: - python -X utf8 browserlauf.py [] -Mit zugang.json (aus der Ablage von start_probe_zugang.py) meldet sich der Lauf einmal an und nutzt die -Sitzung fuer alle Breiten (seit 2026-09-18, weil das Dashboard Anmeldung verlangt); dazu kommt je Breite die -Agenten-Ansicht mit der Walk-Forward-Zeichnung, wenn das Register die Kampagne aus HISTORIE_MARKE traegt. -Prueft je Breite: kein waagerechter Scroll, keine Konsolenfehler, Ansicht "Laeufe" zeigt Zeilen, -Ansicht "Markt" zeichnet (Canvas nicht leer). Schreibt Screenshots und eine ergebnis.json. -Was es nicht sieht: ob die Zahlen stimmen (dafuer pruef_teil3b.py) und wie es aussieht (dafuer -die Screenshots, die der Koordinator selbst ansieht). -""" - -import base64 -import hashlib -import hmac -import json -import re -import struct -import sys -import time -from pathlib import Path - -from playwright.sync_api import TimeoutError as PlaywrightTimeout -from playwright.sync_api import sync_playwright - -BREITEN = [375, 768, 1024, 1440] -# aria-label aus Agenten.tsx: `Historie ${agent}/${version}`; die Kampagne mit Verhaltensblock. -HISTORIE_MARKE = "Historie aapl-minute-ppo/v20260914-132555" - - -def totp(geheimnis: str, zeit_s: float) -> str: - """Eigener Nachbau nach RFC 6238, wie in angriffe.py.""" - schluessel = base64.b32decode(geheimnis.upper() + "=" * (-len(geheimnis) % 8)) - mac = hmac.new(schluessel, struct.pack(">Q", int(zeit_s // 30)), hashlib.sha1).digest() - versatz = mac[-1] & 0x0F - zahl = struct.unpack(">I", mac[versatz : versatz + 4])[0] & 0x7FFFFFFF - return f"{zahl % 1_000_000:06d}" - -# Je ein Minuten- und ein Tagesdatensatz; die Namen stehen so im Katalog (gemessen 2026-09-17). -MARKT_DATENSAETZE = [ - ("minuten", "AAPL.XNAS-1-MINUTE-LAST-EXTERNAL"), - ("tage", "MSFT.XNAS-1-DAY-LAST-EXTERNAL"), -] -# aria-label der Knoepfe aus Laeufe.tsx: `Lauf ${quelle}/${datei}`. -LAEUFE_EINZEL = [ - ("tage", "Lauf signal/tage-teil9-20260917-030800.jsonl"), - ("kampagne", "Lauf laeufe/aapl-minute-ppo-v20260914-132555.jsonl"), -] - -CANVAS_PROBE = """ -() => { - const flaechen = Array.from(document.querySelectorAll('canvas')); - let groesste = null; - for (const c of flaechen) { - if (!groesste || c.width * c.height > groesste.width * groesste.height) groesste = c; - } - if (!groesste || groesste.width === 0 || groesste.height === 0) return {anzahl: flaechen.length, farben: 0}; - const kontext = groesste.getContext('2d'); - const daten = kontext.getImageData(0, 0, groesste.width, groesste.height).data; - const farben = new Set(); - const schritt = Math.max(4, Math.floor(daten.length / 4 / 20000) * 4); - for (let i = 0; i < daten.length; i += schritt) { - farben.add(`${daten[i]},${daten[i+1]},${daten[i+2]},${daten[i+3]}`); - } - return {anzahl: flaechen.length, breite: groesste.width, hoehe: groesste.height, farben: farben.size}; -} -""" - -SCROLL_PROBE = "() => ({scroll: document.documentElement.scrollWidth, sicht: document.documentElement.clientWidth})" - - -def main() -> int: - basis = sys.argv[1].rstrip("/") - ziel = Path(sys.argv[2]) - ziel.mkdir(parents=True, exist_ok=True) - ergebnis: dict = {"basis": basis, "breiten": {}, "befunde": []} - - zugang = json.loads(Path(sys.argv[3]).read_text(encoding="utf-8")) if len(sys.argv) > 3 else None - - with sync_playwright() as spiel: - browser = spiel.chromium.launch() - # Ein Kontext fuer alle Breiten: Ein TOTP-Code gilt einmal, vier Anmeldungen in einer Minute gingen - # also nicht. Die Sitzung haengt am Cookie des Kontexts. - kontext = browser.new_context() - if zugang is not None: - seite = kontext.new_page() - seite.goto(basis + "/", wait_until="networkidle") - seite.get_by_label("Benutzer").fill(zugang["benutzer"]) - seite.get_by_label("Passwort").fill(zugang["passwort"]) - seite.get_by_label("Code").fill(totp(zugang["geheimnis"], time.time())) - seite.get_by_role("button", name="Anmelden", exact=True).click() - try: - seite.get_by_role("button", name="Abmelden").wait_for(timeout=10000) - except PlaywrightTimeout: - ergebnis["befunde"].append("Anmeldung: die App erscheint nicht") - seite.close() - for breite in BREITEN: - seite = kontext.new_page() - seite.set_viewport_size({"width": breite, "height": 900}) - meldungen: list[str] = [] - seite.on("console", lambda m, liste=meldungen: liste.append(f"{m.type}: {m.text}") if m.type in ("error", "warning") else None) - seite.on("pageerror", lambda f, liste=meldungen: liste.append(f"pageerror: {f}")) - seite.goto(basis + "/", wait_until="networkidle") - eintrag: dict = {} - - def messe(name: str) -> None: - masse = seite.evaluate(SCROLL_PROBE) - eintrag[f"scroll_{name}"] = masse - if masse["scroll"] > masse["sicht"] + 1: - ergebnis["befunde"].append(f"{breite}px {name}: waagerechter Scroll {masse}") - alarme = seite.get_by_role("alert").count() - if alarme: - ergebnis["befunde"].append(f"{breite}px {name}: {alarme} Alarm(e): {seite.get_by_role('alert').first.inner_text()[:160]}") - seite.screenshot(path=str(ziel / f"{name}-{breite}.png"), full_page=True) - - for ansicht in ("Läufe", "Markt"): - seite.get_by_role("button", name=ansicht, exact=True).click() - seite.wait_for_timeout(1500) - if ansicht == "Markt": - # Die Ansicht laedt nach Plan nichts von selbst: erst die Auswahl holt Kerzen. - eintrag["canvas"] = {} - for kurz, bar_type in MARKT_DATENSAETZE: - try: - seite.get_by_label("Datensatz").select_option(bar_type) - seite.wait_for_selector("canvas", timeout=20000) - seite.wait_for_timeout(2000) - except PlaywrightTimeout: - pass - probe = seite.evaluate(CANVAS_PROBE) - eintrag["canvas"][kurz] = probe - if probe["farben"] < 5: - ergebnis["befunde"].append(f"{breite}px Markt/{kurz}: kein gezeichnetes Canvas ({probe})") - messe(f"markt-{kurz}") - else: - eintrag["laeufe_zeilen"] = seite.locator("table tbody tr").count() - if eintrag["laeufe_zeilen"] == 0: - ergebnis["befunde"].append(f"{breite}px Laeufe: keine Tabellenzeile") - messe("laeufe") - for kurz, marke in LAEUFE_EINZEL: - try: - seite.get_by_role("button", name=marke, exact=True).click(timeout=8000) - seite.wait_for_load_state("networkidle", timeout=20000) - seite.wait_for_timeout(1200) - except PlaywrightTimeout: - ergebnis["befunde"].append(f"{breite}px Laeufe/{kurz}: Knopf '{marke}' nicht bedienbar") - continue - eintrag[f"einzel_{kurz}_tabellen"] = seite.locator("table").count() - if eintrag[f"einzel_{kurz}_tabellen"] < 2: - ergebnis["befunde"].append(f"{breite}px Laeufe/{kurz}: die Einzelansicht zeigt keine eigene Tabelle") - messe(f"laeufe-{kurz}") - - # Seit 2026-09-18: die Agenten-Ansicht mit der Walk-Forward-Zeichnung (verkettete Episoden). - # Nur mit einem Register, das Versionen traegt; sonst bleibt es beim Bildschirmfoto der leeren Ansicht. - seite.get_by_role("button", name="Agenten", exact=True).click() - seite.wait_for_timeout(1500) - historie = seite.get_by_role("button", name=HISTORIE_MARKE, exact=True) - if historie.count() == 1: - historie.click() - try: - seite.get_by_role("img", name="Kapitalkurven Fenster 1", exact=True).wait_for(timeout=15000) - eintrag["walkforward_zeichnungen"] = seite.get_by_role("img", name=re.compile("^Kapitalkurven Fenster")).count() - except PlaywrightTimeout: - ergebnis["befunde"].append(f"{breite}px Agenten: keine Walk-Forward-Zeichnung") - messe("agenten") - - eintrag["konsole"] = meldungen - if any(m.startswith(("error", "pageerror")) for m in meldungen): - ergebnis["befunde"].append(f"{breite}px: Konsolenfehler {meldungen[:3]}") - ergebnis["breiten"][str(breite)] = eintrag - seite.close() - browser.close() - - (ziel / "ergebnis.json").write_text(json.dumps(ergebnis, indent=2, ensure_ascii=False), encoding="utf-8") - print(json.dumps(ergebnis, indent=2, ensure_ascii=False)) - return 2 if ergebnis["befunde"] else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/browserlauf_zugang.py b/.vault-tools/trading-plattform-pruefung/browserlauf_zugang.py deleted file mode 100644 index a911852..0000000 --- a/.vault-tools/trading-plattform-pruefung/browserlauf_zugang.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Browser-Lauf zur Anmeldung (Teil 10) mit Playwright (Windows-Python), gegen das Probe-Backend mit Zugang. - -Koordinator-Pruefskript, nicht fuers Repo; ein Fangnetz, kein Nachweis. - -Fragen (Herkunft: Projektvorgabe, Spec Teil 10, Teil B): -- Zeigt die Oberflaeche ohne Sitzung das Anmeldeformular und NICHT die App? -- Fuehrt eine falsche Anmeldung zu einer Fehlerzeile (api.ts: "Benutzer, Passwort oder Code stimmt nicht.") und nicht in die App? -- Fuehrt die richtige Anmeldung (Passwort plus frischer TOTP-Code) in die App, mit Benutzername und - Knopf "Abmelden" in der Kopfzeile, laufendem Strom ("Backend verbunden") und der Karte "Paper-Betrieb"? -- Traegt die CSP die gebaute Oberflaeche: keine Konsolenmeldung, die "Content Security Policy" nennt, - in keiner Ansicht (auch "Markt" mit dem Chart)? -- Fuehrt "Abmelden" zurueck zum Formular, und bleibt es nach dem Neuladen dort? -Was es nicht sieht: das Secure-Merkmal und HSTS (dafuer angriffe.py mit einer https-Adresse), ob das -Zertifikat taugt (--selbstsigniert schaltet die Zertifikatspruefung des Browsers ab), Safari und -Firefox (nur Chromium), ob es gut aussieht (dafuer die Screenshots, die der Koordinator selbst ansieht). -Regressionsnachweis: --sabotage erwartet nach der FALSCHEN Anmeldung die App und muss rot enden. -Unter https belegt der Lauf, dass Anmeldung, Strom (wss) und alle Ansichten ueber TLS tragen: Ein -Browser schickt ein Secure-Cookie nur ueber https zurueck, und ohne Cookie gaebe es keinen Strom. - -Aufruf: python -X utf8 browserlauf_zugang.py [--sabotage] [--selbstsigniert] -Exit 0 ohne Befund, 2 mit Befund, 3 wenn eine Voraussetzung fehlt. -""" - -import base64 -import hashlib -import hmac -import json -import struct -import sys -import time -from pathlib import Path - -from playwright.sync_api import TimeoutError as PlaywrightTimeout -from playwright.sync_api import sync_playwright - - -def totp(geheimnis: str, zeit_s: float) -> str: - schluessel = base64.b32decode(geheimnis.upper() + "=" * (-len(geheimnis) % 8)) - mac = hmac.new(schluessel, struct.pack(">Q", int(zeit_s // 30)), hashlib.sha1).digest() - versatz = mac[-1] & 0x0F - zahl = struct.unpack(">I", mac[versatz : versatz + 4])[0] & 0x7FFFFFFF - return f"{zahl % 1_000_000:06d}" - - -def main() -> int: - if len(sys.argv) < 4: - print(__doc__) - return 3 - basis = sys.argv[1].rstrip("/") - zugang_datei = Path(sys.argv[2]) - ziel = Path(sys.argv[3]) - sabotage = "--sabotage" in sys.argv[4:] - selbstsigniert = "--selbstsigniert" in sys.argv[4:] - if not zugang_datei.is_file(): - print(f"VORAUSSETZUNG FEHLT: {zugang_datei}") - return 3 - zugang = json.loads(zugang_datei.read_text(encoding="utf-8")) - ziel.mkdir(parents=True, exist_ok=True) - - befunde: list[str] = [] - konsole: list[str] = [] - antworten: list[str] = [] # jede Antwort ab 400 mit Phase, Status und Pfad - phase = ["start"] - - def pruefe(name: str, bedingung: bool, einzelheit: str = "") -> None: - print((" ok " if bedingung else " ROT ") + name + ("" if bedingung else f": {einzelheit}")) - if not bedingung: - befunde.append(f"{name}: {einzelheit}") - - with sync_playwright() as spiel: - browser = spiel.chromium.launch() - seite = browser.new_page(viewport={"width": 1440, "height": 900}, ignore_https_errors=selbstsigniert) - seite.on("console", lambda m: konsole.append(f"{m.type}: {m.text}") if m.type in ("error", "warning") else None) - seite.on("pageerror", lambda f: konsole.append(f"pageerror: {f}")) - seite.on( - "response", - lambda a: antworten.append(f"{phase[0]} {a.status} {a.request.method} {a.url.replace(basis, '')}") - if a.status >= 400 - else None, - ) - - def formular_da() -> bool: - try: - seite.get_by_label("Passwort").wait_for(timeout=8000) - return True - except PlaywrightTimeout: - return False - - def app_da() -> bool: - try: - seite.get_by_role("button", name="Abmelden").wait_for(timeout=8000) - return True - except PlaywrightTimeout: - return False - - print("== ohne Sitzung") - phase[0] = "ohne Sitzung" - seite.goto(basis + "/") - pruefe("Anmeldeformular sichtbar", formular_da(), "kein Feld 'Passwort'") - pruefe("App nicht sichtbar", seite.get_by_role("button", name="Abmelden").count() == 0, "Knopf 'Abmelden' ist da") - seite.screenshot(path=str(ziel / "01-anmeldung.png")) - - print("== falsche Anmeldung") - phase[0] = "falsche Anmeldung" - seite.get_by_label("Benutzer").fill(zugang["benutzer"]) - seite.get_by_label("Passwort").fill("ganz-falsches-passwort") - seite.get_by_label("Code").fill("000000") - seite.get_by_role("button", name="Anmelden", exact=True).click() - seite.wait_for_timeout(1500) - if sabotage: - pruefe("SABOTAGE: nach der falschen Anmeldung ist die App sichtbar", app_da(), "App nicht sichtbar (so soll es sein)") - else: - pruefe("weiter das Formular", seite.get_by_label("Passwort").count() == 1, "Formular weg") - pruefe("Fehlerzeile 'stimmt nicht'", seite.get_by_text("stimmt nicht").count() >= 1, "kein Fehlertext") - seite.screenshot(path=str(ziel / "02-abgelehnt.png")) - - print("== richtige Anmeldung") - phase[0] = "richtige Anmeldung" - # Ein frischer Zeitschritt, falls der Code dieses Schritts gerade verbraucht wurde. - rest = 30 - (time.time() % 30) - if rest < 5: - time.sleep(rest + 1) - seite.get_by_label("Passwort").fill(zugang["passwort"]) - seite.get_by_label("Code").fill(totp(zugang["geheimnis"], time.time())) - seite.get_by_role("button", name="Anmelden", exact=True).click() - pruefe("App sichtbar (Knopf 'Abmelden')", app_da(), "kein Knopf 'Abmelden'") - pruefe("Benutzername in der Kopfzeile", seite.get_by_text(zugang["benutzer"], exact=True).count() >= 1, "Name fehlt") - try: - seite.get_by_text("Backend verbunden").wait_for(timeout=15000) - verbunden = True - except PlaywrightTimeout: - verbunden = False - pruefe("Strom laeuft ('Backend verbunden')", verbunden, "Statuspunkt zeigt es nicht") - pruefe("Karte 'Paper-Betrieb' da", seite.get_by_text("Paper-Betrieb").count() >= 1, "Karte fehlt") - seite.wait_for_timeout(1500) - seite.screenshot(path=str(ziel / "03-uebersicht.png"), full_page=True) - - print("== Ansichten unter der CSP") - phase[0] = "Ansichten unter der CSP" - for name in ("Agenten", "Laeufe", "Markt", "Bestand"): - knopf = seite.get_by_role("button", name=name.replace("ae", "ä") if name == "Laeufe" else name) - if knopf.count() == 0: - knopf = seite.get_by_role("link", name=name.replace("ae", "ä") if name == "Laeufe" else name) - if knopf.count() == 0: - pruefe(f"Ansicht {name} erreichbar", False, "kein Knopf und kein Link mit diesem Namen") - continue - knopf.first.click() - seite.wait_for_timeout(2500) - seite.screenshot(path=str(ziel / f"04-{name.lower()}.png")) - csp_meldungen = [m for m in konsole if "Content Security Policy" in m or "content security policy" in m.lower()] - pruefe("keine CSP-Meldung in der Konsole", not csp_meldungen, " | ".join(csp_meldungen[:3])) - # Chromium schreibt jede Antwort ab 400 als Konsolenfehler. Die 401 der absichtlich falschen - # Anmeldung ist erwartet; jede andere Antwort ab 400 wird mit Phase und Pfad zum Befund. - unerwartet = [a for a in antworten if not (" 401 POST /api/anmeldung" in a and a.startswith("falsche Anmeldung"))] - pruefe("keine unerwartete Antwort ab 400", not unerwartet, " | ".join(unerwartet[:5])) - andere = [m for m in konsole if m not in csp_meldungen and "Failed to load resource" not in m] - pruefe("keine andere Fehlermeldung in der Konsole", not andere, " | ".join(andere[:3])) - - print("== Abmelden") - phase[0] = "Abmelden" - seite.get_by_role("button", name="Abmelden").click() - pruefe("zurueck zum Formular", formular_da(), "Formular erscheint nicht") - seite.reload() - pruefe("nach dem Neuladen weiter das Formular", formular_da(), "Formular erscheint nicht") - seite.screenshot(path=str(ziel / "05-abgemeldet.png")) - - print("== schmale Breite") - phase[0] = "schmale Breite" - seite.set_viewport_size({"width": 375, "height": 800}) - seite.reload() - pruefe("Formular bei 375 px", formular_da(), "Formular erscheint nicht") - masse = seite.evaluate("() => ({scroll: document.documentElement.scrollWidth, sicht: document.documentElement.clientWidth})") - pruefe("kein waagerechter Scroll bei 375 px", masse["scroll"] <= masse["sicht"], str(masse)) - seite.screenshot(path=str(ziel / "06-anmeldung-375.png")) - browser.close() - - (ziel / "konsole.json").write_text(json.dumps(konsole, ensure_ascii=False, indent=1), encoding="utf-8") - (ziel / "antworten-ab-400.json").write_text(json.dumps(antworten, ensure_ascii=False, indent=1), encoding="utf-8") - print(f"\n{len(befunde)} Befund(e); Screenshots unter {ziel}") - for befund in befunde: - print("BEFUND:", befund) - return 2 if befunde else 0 - - -if __name__ == "__main__": - try: - sys.exit(main()) - except Exception as ausnahme: # noqa: BLE001 - ein Absturz des Pruefskripts ist selbst ein Befund - print(f"BEFUND: das Pruefskript ist abgebrochen: {ausnahme!r}") - sys.exit(2) diff --git a/.vault-tools/trading-plattform-pruefung/c_eigenlauf.py b/.vault-tools/trading-plattform-pruefung/c_eigenlauf.py deleted file mode 100644 index 2a7b296..0000000 --- a/.vault-tools/trading-plattform-pruefung/c_eigenlauf.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Eigener Lauf zu Paket C: Ein entfernter Benutzer verliert Sitzung und offenen Strom, der andere bleibt. - -Koordinator-Werkzeug, nicht fuers Repo. Frage (Herkunft: Anforderung Paket C vom 2026-09-18, C1, und die -Plankritik): Endet mit dem Entfernen eines Benutzers aus der Zugangsdatei wirklich alles, was er offen hat -- -die HTTP-Sitzung (401), der schreibende Weg zum Killswitch (401) und der schon offene Uebersichtsstrom -(Schliessen mit 1008) --, waehrend ein zweiter Benutzer ungestoert weiterarbeitet? Und antwortet das Backend -auf eine halb geschriebene Zugangsdatei mit einer Ablehnung statt mit einem 500? -Gegenstand ist das Probe-Backend aus start_probe_zugang.py (Attrappe, Wegwerf-Register, Wegwerf-Zugang), -gestartet mit zwei-benutzer. Entfernt wird ueber das Skript des Projekts, nicht von Hand. -Was es nicht sieht: einen Browser (dafuer browserlauf_zugang.py) und den echten Broker. -Regressionsnachweis: --sabotage laesst das Entfernen aus; die Pruefungen auf 401 und 1008 muessen dann rot enden. - -Aufruf (WSL, aus dem Codebaum): python c_eigenlauf.py [--sabotage] -Exit 0 ohne Befund, 2 mit Befund, 3 wenn eine Voraussetzung fehlt. -""" - -import base64 -import hashlib -import hmac -import json -import struct -import subprocess -import sys -import time -from pathlib import Path - -import requests -from websockets.exceptions import ConnectionClosed -from websockets.sync.client import connect - -befunde: list[str] = [] - - -def pruefe(name: str, bedingung: bool, einzelheit: str = "") -> None: - print(f" {'ok ' if bedingung else 'BEFUND'} {name}: {einzelheit}") - if not bedingung: - befunde.append(name) - - -def totp(geheimnis: str, zeit_s: float) -> str: - schluessel = base64.b32decode(geheimnis.upper() + "=" * (-len(geheimnis) % 8)) - mac = hmac.new(schluessel, struct.pack(">Q", int(zeit_s // 30)), hashlib.sha1).digest() - versatz = mac[-1] & 0x0F - zahl = struct.unpack(">I", mac[versatz : versatz + 4])[0] & 0x7FFFFFFF - return f"{zahl % 1_000_000:06d}" - - -def anmelden(basis: str, zugang: dict) -> requests.Session: - sitzung = requests.Session() - antwort = sitzung.post( - f"{basis}/api/anmeldung", - json={"benutzer": zugang["benutzer"], "passwort": zugang["passwort"], "code": totp(zugang["geheimnis"], time.time())}, - headers={"Origin": basis}, - timeout=15, - ) - pruefe(f"Anmeldung {zugang['benutzer']}", antwort.status_code == 200, f"HTTP {antwort.status_code}") - return sitzung - - -def cookie(sitzung: requests.Session) -> str: - return "; ".join(f"{c.name}={c.value}" for c in sitzung.cookies) - - -def main() -> int: - if len(sys.argv) < 3: - print(__doc__) - return 3 - basis, ablage = sys.argv[1].rstrip("/"), Path(sys.argv[2]) - sabotage = "--sabotage" in sys.argv[3:] - if totp("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", 59) != "287082": - print("VORAUSSETZUNG FEHLT: der eigene TOTP-Nachbau trifft den RFC-Vektor nicht") - return 3 - try: - erster = json.loads((ablage / "zugang.json").read_text(encoding="utf-8")) - zweiter = json.loads((ablage / "zugang2.json").read_text(encoding="utf-8")) - except OSError: - print("VORAUSSETZUNG FEHLT: zugang.json und zugang2.json in der Ablage (Backend mit zwei-benutzer starten)") - return 3 - zugang_datei = ablage / "zugang.toml" - ws_url = basis.replace("http", "ws", 1) + "/ws/uebersicht" - - print("== Beide melden sich an, beide Stroeme laufen") - s1, s2 = anmelden(basis, erster), anmelden(basis, zweiter) - with connect(ws_url, additional_headers={"Cookie": cookie(s1)}, origin=basis, open_timeout=5) as ws1, connect( - ws_url, additional_headers={"Cookie": cookie(s2)}, origin=basis, open_timeout=5 - ) as ws2: - pruefe("Strom des ersten liefert", "zeit" in str(ws1.recv(timeout=10)), "erster Takt da") - pruefe("Strom des zweiten liefert", "zeit" in str(ws2.recv(timeout=10)), "erster Takt da") - - print("== Der erste Benutzer wird ueber das Skript entfernt") - if sabotage: - print(" sabotage: das Entfernen faellt aus") - else: - lauf = subprocess.run( - [sys.executable, "scripts/zugang.py", "--datei", str(zugang_datei), "entfernen", erster["benutzer"]], - input=erster["benutzer"] + "\n", capture_output=True, text=True, timeout=60, - ) - pruefe("Skript entfernt mit Exit 0", lauf.returncode == 0, lauf.stdout.strip().splitlines()[-1][:120] if lauf.stdout.strip() else lauf.stderr[:120]) - liste = subprocess.run( - [sys.executable, "scripts/zugang.py", "--datei", str(zugang_datei), "liste"], capture_output=True, text=True, timeout=60 - ) - pruefe("liste nennt nur noch den zweiten", zweiter["benutzer"] in liste.stdout and erster["benutzer"] not in liste.stdout, liste.stdout.strip().replace("\n", " | ")[:160]) - pruefe("liste zeigt kein Geheimnis", zweiter["geheimnis"] not in liste.stdout and "scrypt" not in liste.stdout.lower(), f"{len(liste.stdout)} Zeichen") - - print("== Der erste ist draussen") - antwort = s1.get(f"{basis}/api/uebersicht", timeout=15) - pruefe("GET /api/uebersicht 401", antwort.status_code == 401, f"HTTP {antwort.status_code}") - antwort = s1.post(f"{basis}/api/killswitch/anhalten", json={"grund": "probe"}, headers={"Origin": basis}, timeout=15) - pruefe("POST /api/killswitch/anhalten 401", antwort.status_code == 401, f"HTTP {antwort.status_code}") - antwort = s1.get(f"{basis}/api/sitzung", timeout=15) - pruefe("/api/sitzung meldet nicht angemeldet", antwort.status_code == 200 and antwort.json().get("angemeldet") is False, antwort.text[:100]) - beginn = time.monotonic() - geschlossen, takte = None, 0 - try: - while time.monotonic() - beginn < 12: - ws1.recv(timeout=6) - takte += 1 - except ConnectionClosed as ausnahme: - geschlossen = ausnahme.rcvd.code if ausnahme.rcvd else None - except TimeoutError: - pass - pruefe("offener Strom des ersten schliesst mit 1008", geschlossen == 1008, f"Code {geschlossen}, davor noch {takte} Takt(e), nach {time.monotonic() - beginn:.1f} s") - pruefe("hoechstens ein Takt rutscht noch durch", takte <= 1, f"{takte} Takt(e)") - - print("== Der zweite arbeitet weiter") - antwort = s2.get(f"{basis}/api/uebersicht", timeout=15) - pruefe("GET /api/uebersicht des zweiten 200", antwort.status_code == 200, f"HTTP {antwort.status_code}") - try: - pruefe("Strom des zweiten liefert weiter", "zeit" in str(ws2.recv(timeout=10)), "Takt da") - except Exception as ausnahme: # noqa: BLE001 - jeder Abbruch hier ist der Befund - pruefe("Strom des zweiten liefert weiter", False, repr(ausnahme)[:120]) - - print("== Halb geschriebene Zugangsdatei") - original = zugang_datei.read_bytes() - try: - zugang_datei.write_text('[benutzer.halb]\npasswort = "x"\n', encoding="utf-8") - time.sleep(0.05) - antwort = s2.get(f"{basis}/api/uebersicht", timeout=15) - pruefe("Ablehnung statt 500", antwort.status_code in (401, 503), f"HTTP {antwort.status_code}") - finally: - zugang_datei.write_bytes(original) - time.sleep(0.05) - antwort = s2.get(f"{basis}/api/uebersicht", timeout=15) - # Seit der Nachbesserung vom 2026-09-18: Eine unlesbare Datei sperrt nur aus, solange sie unlesbar ist. - pruefe("nach dem Zuruecklegen gilt die Sitzung des zweiten wieder", antwort.status_code == 200, f"HTTP {antwort.status_code}") - - print(f"{len(befunde)} Befund(e).") - return 2 if befunde else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/d_eigenlauf.py b/.vault-tools/trading-plattform-pruefung/d_eigenlauf.py deleted file mode 100644 index ff1586e..0000000 --- a/.vault-tools/trading-plattform-pruefung/d_eigenlauf.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Eigener Lauf zu Paket D: Der echte Melder schickt ueber echtes HTTP an einen lokalen ntfy-Probeserver. - -Koordinator-Werkzeug, nicht fuers Repo. Frage (Herkunft: Anforderung Paket D vom 2026-09-18, "Lauf mit echten -Daten"): Was geht wirklich ueber die Leitung, wenn der Runner und der Stille-Waechter mit dem ausgelieferten -Melder arbeiten -- wie viele Anfragen, welche Titel und Prioritaeten, steht ein Kontowert oder das Thema im Text, -und was passiert bei abgelehnten und bei langsamen Antworten? Die Tests des Plans spritzen einen Transport ein; -hier laeuft requests.post gegen 127.0.0.1. -Der Sitzungsaufbau (Probe-Broker, simulierte Uhr, Wegwerf-Register) kommt aus den Helfern der Testdatei -tests/test_paper_sitzung_melder.py des Codebaums; der Melder ist der echte, ohne eingespritzten Transport. -Was es nicht sieht: ntfy.sh selbst (Themenregeln des Dienstes, Ratenbegrenzung, Zustellung aufs Telefon) und den -echten Broker -- dafuer braucht es ein Thema von Pascal und einen Handelstag. -Regressionsnachweis: --sabotage erwartet im ersten Fall absichtlich zwei Meldungen statt einer und muss rot enden. - -Aufruf (WSL, PYTHONPATH=, Arbeitsverzeichnis beliebig): python d_eigenlauf.py [--sabotage] -Exit 0 ohne Befund, 2 mit Befund. -""" - -import json -import os -import subprocess -import sys -import tempfile -import time -from datetime import UTC -from datetime import datetime -from datetime import timedelta -from pathlib import Path - -CODE = Path(sys.argv[1]) -sys.path.insert(0, str(CODE)) -sys.path.insert(0, str(CODE / "tests")) - -import test_paper_sitzung_melder as helfer # noqa: E402 -from trading.broker.schnittstelle import BrokerFehler # noqa: E402 -from trading.dashboard.waechter import StilleWaechter # noqa: E402 -from trading.paper.melder import Melder # noqa: E402 -from trading.paper.melder import MelderConfig # noqa: E402 - -SERVER = Path(__file__).with_name("ntfy_probe_server.py") -THEMA = "lokales-probe-thema" -TOKEN = "tk_nur_fuer_die_probe" -befunde: list[str] = [] - - -class Probeserver: - def __init__(self, port: int, *zusatz: str) -> None: - self.port = port - self.ordner = tempfile.mkdtemp(prefix="ntfy-probe-") - self.mitschrift = Path(self.ordner) / "mitschrift.jsonl" - self.prozess = subprocess.Popen( - [sys.executable, str(SERVER), str(port), str(self.mitschrift), *zusatz], - stdout=subprocess.DEVNULL, - ) - time.sleep(0.8) - - def zeilen(self) -> list[dict]: - if not self.mitschrift.exists(): - return [] - with open(self.mitschrift, encoding="utf-8") as datei: - return [json.loads(zeile) for zeile in datei if zeile.strip()] - - def ende(self) -> None: - self.prozess.terminate() - self.prozess.wait(timeout=5) - - -def melder(port: int, zeitlimit_s: float = 3.0) -> Melder: - return Melder( - config=MelderConfig( - aktiv=True, - ntfy_url=f"http://127.0.0.1:{port}", - thema=THEMA, - zeitlimit_s=zeitlimit_s, - stille_nach_s=180.0, - ), - token=TOKEN, - ) - - -def pruefe(name: str, bedingung: bool, einzelheit: str) -> None: - print(f" {'ok ' if bedingung else 'BEFUND'} {name}: {einzelheit}") - if not bedingung: - befunde.append(name) - - -def auf_der_leitung(name: str, zeilen: list[dict], erwartete_titel: list[str]) -> None: - titel = [z["title"] for z in zeilen] - pruefe(f"{name}: Titel", titel == erwartete_titel, f"{titel}") - for z in zeilen: - text = z["text"] - pruefe(f"{name}: Pfad ist das Thema", z["pfad"] == f"/{THEMA}", z["pfad"]) - pruefe(f"{name}: Titel ist ASCII", z["title"].isascii(), z["title"]) - pruefe(f"{name}: Bearer-Kopf da", z["authorization_schema"] == "Bearer", f"Laenge {z['authorization_laenge']}") - pruefe(f"{name}: weder Thema noch Token im Text", THEMA not in text and TOKEN not in text, f"{len(text)} Zeichen") - pruefe( - f"{name}: kein Kontowert im Text", - "100000" not in text.replace(".", "").replace(",", "") and "Konto" not in text and "cash" not in text.lower(), - text.replace("\n", " | ")[:160], - ) - - -def fall_sitzung(name, port, server_zusatz, erwartete_titel, sabotage=False, zeitlimit_s=3.0, **aufbau_kw): - print(f"== {name}") - server = Probeserver(port, *server_zusatz) - try: - with tempfile.TemporaryDirectory() as ordner: - ohne = helfer.Aufbau(Path(ordner) / "ohne", melder=None, **_kw(aufbau_kw)) - code_ohne = ohne.sitzung.laufe() - mit = helfer.Aufbau(Path(ordner) / "mit", melder=melder(port, zeitlimit_s), **_kw(aufbau_kw)) - beginn = time.monotonic() - code_mit = mit.sitzung.laufe() - dauer = time.monotonic() - beginn - pruefe(f"{name}: Exit-Code wie ohne Melder", code_mit == code_ohne, f"{code_mit} gegen {code_ohne}") - pruefe(f"{name}: Grund wie ohne Melder", mit.ende()["grund"] == ohne.ende()["grund"], mit.ende()["grund"]) - zeilen = server.zeilen() - if sabotage: - erwartete_titel = [*erwartete_titel, "Paper-Runner beendet"] - auf_der_leitung(name, zeilen, erwartete_titel) - return mit, zeilen, dauer - finally: - server.ende() - - -def _kw(aufbau_kw: dict) -> dict: - kw = dict(aufbau_kw) - kw.setdefault("kerzen", helfer._kerzen()) - kw.setdefault("agent", helfer.Agent(aktion=3)) - return kw - - -def main() -> int: - sabotage = "--sabotage" in sys.argv - basis = 18200 + (os.getpid() % 500) - - fall_sitzung("ruhiger Tag", basis, (), ["Paper-Runner beendet"], sabotage=sabotage) - fall_sitzung( - "Watchdog haelt an", basis + 1, (), ["Paper-Runner pausiert", "Paper-Runner beendet"], - wirft_bei={"letzter_kurs": BrokerFehler("500 Internal Server Error")}, - ) - fall_sitzung( - "Tagesverlust", basis + 2, (), ["Grenze verletzt", "Paper-Runner beendet"], - kerzen=helfer._kerzen(60, trend=-0.15, sigma=0.0), agent=helfer.Agent(aktion=4), - config=helfer._config(basis_menge=200), limits=helfer._limits(max_tagesverlust_anteil=0.002), - ) - - mit, zeilen, _ = fall_sitzung( - "Server lehnt ab (403)", basis + 3, ("--status", "403"), ["Paper-Runner gestoert", "Paper-Runner beendet"], - fuellung="nie", - ) - fehlschlaege = [z.get("grund") or z.get("text") or z for z in mit.fehlschlaege()] - pruefe("403: je Meldung eine Protokollzeile", len(mit.fehlschlaege()) == len(zeilen) == 2, f"{fehlschlaege}") - pruefe("403: die Zeile nennt den Status und nicht das Thema", all("403" in json.dumps(z) and THEMA not in json.dumps(z) for z in mit.fehlschlaege()), "geprueft an json.dumps der Zeilen") - - mit, zeilen, dauer = fall_sitzung( - "Server antwortet zu langsam", basis + 4, ("--verzoegerung", "2.5"), ["Paper-Runner beendet"], zeitlimit_s=1.0, - ) - pruefe("langsam: Fehlschlag protokolliert", len(mit.fehlschlaege()) == 1, f"{[json.dumps(z)[:120] for z in mit.fehlschlaege()]}") - pruefe("langsam: der Lauf wartet hoechstens das Zeitlimit", dauer < 2.4, f"{dauer:.2f} s fuer den ganzen Tag mit einer Meldung, Zeitlimit 1,0 s") - - mit, zeilen, _ = fall_sitzung( - "Server leitet weiter (307)", basis + 6, ("--status", "307"), ["Paper-Runner beendet"], - ) - pruefe("307: genau eine Anfrage, keiner Weiterleitung gefolgt", len(zeilen) == 1 and zeilen[0]["pfad"] == f"/{THEMA}", f"{[z['pfad'] for z in zeilen]}") - pruefe("307: als Fehlschlag protokolliert", [z.get("text") for z in mit.fehlschlaege()] == ["HTTP 307"], f"{[z.get('text') for z in mit.fehlschlaege()]}") - - print("== Stille-Waechter wiederholt bis zur Zustellung") - server = Probeserver(basis + 5, "--status-folge", "403,403,200") - try: - with tempfile.TemporaryDirectory() as ordner: - status = Path(ordner) / "status.json" - jetzt = datetime.now(UTC) - status.write_text(json.dumps({"zeit": (jetzt - timedelta(seconds=600)).isoformat(), "phase": "handel", "position": 0, "pid": 1}), encoding="utf-8") - waechter = StilleWaechter(melder=melder(basis + 5), status_datei=status, stille_nach_s=180.0) - for _ in range(5): - waechter.pruefe() - zeilen = server.zeilen() - pruefe("Waechter: drei Anfragen, dann Ruhe", [z["status"] for z in zeilen] == [403, 403, 200], f"{[z['status'] for z in zeilen]}") - pruefe("Waechter: Titel und Prioritaet", all(z["title"] == "Runner schweigt" and z["priority"] == "urgent" for z in zeilen), f"{[(z['title'], z['priority']) for z in zeilen]}") - pruefe("Waechter: zwei Fehlschlaege gemerkt", waechter.fehlschlaege == ["HTTP 403", "HTTP 403"], f"{waechter.fehlschlaege}") - status.write_text(json.dumps({"zeit": datetime.now(UTC).isoformat(), "phase": "handel", "position": 0, "pid": 1}), encoding="utf-8") - waechter.pruefe() - waechter.pruefe() - zeilen = server.zeilen() - pruefe("Waechter: genau eine Rueckmeldung", [z["title"] for z in zeilen[3:]] == ["Runner meldet sich wieder"], f"{[z['title'] for z in zeilen[3:]]}") - finally: - server.ende() - - print(f"{len(befunde)} Befund(e).") - return 2 if befunde else 0 - - -if __name__ == "__main__": - try: - sys.exit(main()) - except Exception as ausnahme: # noqa: BLE001 - ein Absturz des Werkzeugs ist selbst ein Befund - print(f"BEFUND: das Werkzeug ist abgebrochen: {ausnahme!r}") - raise diff --git a/.vault-tools/trading-plattform-pruefung/dashboard_neustart.sh b/.vault-tools/trading-plattform-pruefung/dashboard_neustart.sh deleted file mode 100644 index 07d5194..0000000 --- a/.vault-tools/trading-plattform-pruefung/dashboard_neustart.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash -# Dashboard-Neustart nach einem Einspielen, mit Tor 6b (liest das Dashboard die Geheimnisdatei unter PrivateUsers=yes?). -# Koordinator-Werkzeug, nicht fuers Repo. Pascals dauerhafte Freigabe gilt fuer genau diesen Neustart, nur ausserhalb der -# Handelszeit; das Skript verweigert, solange der Runner laeuft. Aufruf aus der PowerShell mit WSL_PW per WSLENV: -# wsl -d Debian -u claude -- bash /mnt/d/Claude/.vault-tools/trading-plattform-pruefung/dashboard_neustart.sh -set -u -[ -n "${WSL_PW:-}" ] || { echo "WSL_PW fehlt"; exit 20; } -S() { printf '%s\n' "$WSL_PW" | sudo -S -p '' "$@"; } -if systemctl is-active --quiet trading-paper.service; then echo "Runner laeuft - kein Neustart"; exit 21; fi -date -S systemctl restart trading-dashboard.service || exit 22 -sleep 8 -systemctl is-active trading-dashboard.service -systemctl show trading-dashboard.service --property=NRestarts,ActiveEnterTimestamp -journalctl -u trading-dashboard.service --since "-1min" --no-pager | tail -10 | sed -E 's/[0-9]{6,}/######/g' -if journalctl -u trading-dashboard.service --since "-1min" --no-pager | grep -E "kann sie aber nicht lesen|PermissionError|secrets.toml|Traceback"; then - echo "TOR 6b ZU"; exit 27 -fi -echo "Tor 6b offen" -code=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: 127.0.0.1:8144" http://127.0.0.1:8144/api/gesund) -echo "api/gesund: $code" -code=$(curl -s -o /dev/null -w "%{http_code}" -H "Host: 127.0.0.1:8144" http://127.0.0.1:8144/) -echo "Oberflaeche /: $code" diff --git a/.vault-tools/trading-plattform-pruefung/dienste_stand.sh b/.vault-tools/trading-plattform-pruefung/dienste_stand.sh deleted file mode 100644 index f3e79ad..0000000 --- a/.vault-tools/trading-plattform-pruefung/dienste_stand.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/bash -# Nur lesend: Stand der eingerichteten Dienste der Trading Plattform in WSL-Debian. -# Das Journal liest, wer in der Gruppe adm oder systemd-journal ist (hier: claude in adm); -# sonst mit sudo -- dann das Passwort ueber WSLENV als WSL_PW durchreichen, nie ausgeben. -PROJEKT="/mnt/d/Claude/Code Projekte/active - Trading Plattform" -journal() { - if journalctl -u "$1" --no-pager -n 1 >/dev/null 2>&1; then - journalctl -u "$1" --no-pager -n "$2" -o short-iso | cut -c1-200 - elif [ -n "$WSL_PW" ]; then - printf '%s\n' "$WSL_PW" | sudo -S -p '' journalctl -u "$1" --no-pager -n "$2" -o short-iso | cut -c1-200 - else - echo "(Journal nicht lesbar: weder Gruppe adm/systemd-journal noch WSL_PW)" - fi -} -echo "=== $(date -Is) ===" -echo "--- dateien ---" -ls -la /etc/systemd/system/ | grep -i "trading" | awk '{print $1, $5, $9}' -echo "--- enabled ---" -for u in trading-dashboard.service trading-paper.timer trading-paper.service; do - printf '%s: ' "$u"; systemctl is-enabled "$u" 2>&1 -done -echo "--- zustand ---" -systemctl show -p ActiveState -p SubState -p Result -p ExecMainStartTimestamp -p NRestarts trading-dashboard.service | tr '\n' ' '; echo -systemctl show -p ActiveState -p SubState -p Result -p ExecMainStartTimestamp -p ExecMainExitTimestamp -p ExecMainStatus trading-paper.service | tr '\n' ' '; echo -echo "--- zeitgeber ---" -systemctl list-timers trading-paper.timer --all --no-pager | head -4 -echo "--- gesund ---" -curl -s -o /dev/null -w 'dashboard in WSL: HTTP %{http_code}\n' --max-time 4 http://127.0.0.1:8144/api/gesund -echo "--- systemd laeuft seit ---" -systemctl show -p UserspaceTimestamp --value -echo "--- journal runner (letzte 16) ---" -journal trading-paper.service 16 -echo "--- journal dashboard (letzte 8) ---" -journal trading-dashboard.service 8 -echo "--- status.json und tagesstart.json (ohne Kontowerte) ---" -python3 - "$PROJEKT" <<'EOF' -import json -import sys -from pathlib import Path - -ordner = Path(sys.argv[1]) / "data" / "paper" -for name, felder in ( - ("status.json", ("zeit", "phase", "killswitch", "position", "letzter_grund", "pid", "boot")), - ("tagesstart.json", ("handelstag",)), -): - try: - daten = json.loads((ordner / name).read_text(encoding="utf-8")) - except (OSError, ValueError) as ausnahme: - print(f"{name}: nicht lesbar ({type(ausnahme).__name__})") - continue - print(name, {k: daten.get(k) for k in felder}) -EOF diff --git a/.vault-tools/trading-plattform-pruefung/geheimnis_scan.py b/.vault-tools/trading-plattform-pruefung/geheimnis_scan.py deleted file mode 100644 index 77874d7..0000000 --- a/.vault-tools/trading-plattform-pruefung/geheimnis_scan.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Vor dem Push: kommt ein Wert aus der Geheimnis- oder Zugangsdatei in den neuen Commits vor? - -Koordinator-Werkzeug, nicht fuers Repo (Pascal 2026-09-24: Push jederzeit, vorher dieser Scan). -Regressionsnachweis vor jedem Scan: SCAN_PROBE= muss einen Treffer melden. - -Liest die Werte, gibt sie nie aus, nur Schluesselname und Trefferzahl. Laeuft in WSL als claude. -Aufruf: python geheimnis_scan.py [ ...] -""" -import subprocess -import sys -import tomllib -from pathlib import Path - -GEHEIM = Path("/home/claude/.config/trading-plattform/secrets.toml") -repo = Path(sys.argv[1]) -von = sys.argv[2] -ziele = sys.argv[3:] - - -def blaetter(tabelle, pfad=""): - for schluessel, wert in tabelle.items(): - name = f"{pfad}.{schluessel}" if pfad else schluessel - if isinstance(wert, dict): - yield from blaetter(wert, name) - elif isinstance(wert, str): - yield name, wert - - -werte = [] -for datei in (GEHEIM, repo / "config" / "zugang.toml"): - if not datei.exists(): - print(f"BEFUND: {datei.name} fehlt, Scan ohne diese Werte") - continue - with datei.open("rb") as f: - daten = tomllib.load(f) - for name, wert in blaetter(daten): - # Kurze Werte (true/false, Hostnamen wie localhost, Modellnamen) treffen Prosa. - if len(wert) >= 12: - werte.append((f"{datei.name}:{name}", wert)) -schluessel_json = repo / "config" / "zugang-schluessel.json" -if schluessel_json.exists(): - import json - for i, eintrag in enumerate(json.loads(schluessel_json.read_text(encoding="utf-8")) or []): - if isinstance(eintrag, dict): - for k, v in eintrag.items(): - if isinstance(v, str) and len(v) >= 12: - werte.append((f"zugang-schluessel.json:{i}.{k}", v)) -# Seit 2026-09-24 (Paket IB): die IBKR-Zugangsdateien. Die Kontonummer hat 9 Zeichen, deshalb -# hier ab 6 statt ab 12; diese drei Werte stehen in keiner Prosa. -IBKR = Path("/home/claude/.config/trading-plattform/ibkr") -for name in ("paper_benutzer", "paper_passwort", "paper_konto"): - datei = IBKR / name - if datei.exists(): - wert = datei.read_text(encoding="utf-8").strip() - if len(wert) >= 6: - werte.append((f"ibkr:{name}", wert)) - else: - print(f"BEFUND: ibkr/{name} fehlt, Scan ohne diesen Wert") -import os -if os.environ.get("SCAN_PROBE"): - # Regressionsnachweis: ein bekannter Text aus dem Verlauf muss als Treffer erscheinen. - werte.append(("probe", os.environ["SCAN_PROBE"])) -print(f"geprueft: {len(werte)} Werte ab 12 Zeichen") - -befunde = 0 -for ziel in ziele: - text = subprocess.run( - ["git", "-c", "safe.directory=*", "-C", str(repo), "log", "-p", "--all-match", f"{von}..{ziel}"], - capture_output=True, text=True, encoding="utf-8", errors="replace", - ).stdout - print(f"{von}..{ziel}: {len(text)} Zeichen Verlauf") - for name, wert in werte: - n = text.count(wert) - if n: - befunde += 1 - print(f" TREFFER {name}: {n}") - verfolgt = subprocess.run( - ["git", "-c", "safe.directory=*", "-C", str(repo), "ls-tree", "-r", "--name-only", ziel], - capture_output=True, text=True, - ).stdout.splitlines() - for gesperrt in ("config/secrets.toml", "config/zugang.toml", "config/zugang-schluessel.json"): - if gesperrt in verfolgt: - befunde += 1 - print(f" TREFFER verfolgt: {gesperrt}") -print(f"ERGEBNIS: {befunde} Treffer") -sys.exit(1 if befunde else 0) diff --git a/.vault-tools/trading-plattform-pruefung/generalprobe_echtlauf.py b/.vault-tools/trading-plattform-pruefung/generalprobe_echtlauf.py deleted file mode 100644 index 5224974..0000000 --- a/.vault-tools/trading-plattform-pruefung/generalprobe_echtlauf.py +++ /dev/null @@ -1,343 +0,0 @@ -"""Eigener Nachtlauf der Generalprobe: _generalprobe aus scripts/paper.py mit echten Kerzen, ohne Netz. - -Koordinator-Pruefcode, nicht Teil des Repos. Gerufen wird die echte Funktion _generalprobe -- dieselbe -Verdrahtung wie um 9:00 Uhr --, umgebogen sind nur die Stellen, die sonst ins Netz oder in den Betrieb -gingen: - - REGISTER zeigt auf eine Sicherungskopie des echten Registers in einem Wegwerf-Ordner (das echte wird - nur lesend geoeffnet), ARTEFAKTE auf den echten artifacts-Ordner (nur lesen: das Registermodell). - - Statusdatei und Protokollordner liegen im Wegwerf-Ordner. - - AlpacaKerzenquelle.from_secrets liefert die echten IEX-Kerzen des Tages aus einer JSON-Datei. - - AlpacaBroker.from_secrets liefert ein mitschreibendes Konto, das glatt ist und bei jedem schreibenden - Aufruf wirft -- damit steht im Bericht, was die Generalprobe am "echten" Broker ruft. - - requests.Session.request schreibt mit und wirft: Jeder Netzversuch des Laufs faellt auf. - - Der Melder ist ein echter Melder mit mitschreibendem Transport, in der Huelle GeneralprobeMelder. -"Pascal" klickt aus einem zweiten Faden -- wie das Dashboard mit einem frischen Killswitch-Objekt aus dem -Register, am mitschreibenden Konto: fortsetzen, spaeter anhalten und fortsetzen, glattstellen, freigeben. - -Aufruf (WSL): python generalprobe_echtlauf.py [tempo] -""" - -import importlib.util -import json -import os -import sqlite3 -import sys -import threading -import time -from dataclasses import replace -from datetime import UTC -from datetime import datetime -from datetime import timedelta -from pathlib import Path - -CODE = Path(sys.argv[1]).resolve() -ECHT = Path(sys.argv[2]).resolve() -KERZEN = Path(sys.argv[3]).resolve() -WEG = Path(sys.argv[4]).resolve() -TEMPO = float(sys.argv[5]) if len(sys.argv) > 5 else 1.0 - -os.chdir(CODE) -sys.path.insert(0, str(CODE)) - -import requests # noqa: E402 - -from trading.broker.schnittstelle import Marktuhr # noqa: E402 -from trading.killswitch.killswitch import Killswitch # noqa: E402 -from trading.killswitch.killswitch import lade_killswitch_config # noqa: E402 -from trading.paper.config import lade_paper_config # noqa: E402 -from trading.paper.kerzen import HANDELSZONE # noqa: E402 -from trading.paper.kerzen import Kerze # noqa: E402 -from trading.paper.melder import Melder # noqa: E402 -from trading.paper.melder import MelderConfig # noqa: E402 -from trading.paper.status import lies_status # noqa: E402 -from trading.registry.registry import Registry # noqa: E402 - -NETZVERSUCHE: list[str] = [] - - -def _kein_netz(self, method, url, *args, **kwargs): - NETZVERSUCHE.append(f"{method} {url.split('?')[0]}") - raise requests.ConnectionError("Nachtlauf: 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"), - "erste_zeile": rumpf.split("\n")[0], - "echt": datetime.now(UTC).isoformat(timespec="seconds"), - } - ) - 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 lade_kerzen(pfad): - roh = json.loads(Path(pfad).read_text(encoding="utf-8")) - return [ - 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 - ] - - -def main(): - kerzen = lade_kerzen(KERZEN) - 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() - # Die Spalte artefakt_pfad ist relativ zum Projektordner. In der Kopie wird sie 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() - - spec = importlib.util.spec_from_file_location("paper_skript_nachtlauf", CODE / "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="nachtlauf-geht-nie-ins-netz", - zeitlimit_s=3.0, stille_nach_s=180.0), - token="", transport=transport, - ) - - beobachtet = {"max_alter_s": 0.0, "modi": set(), "marktzeiten": [], "phasen": [], "klicks": [], - "rueckwaerts": 0, "position_nach_glatt": None, "fehler": []} - 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 klick(was, marktzeit, position): - ks = dashboard_killswitch() - vorher = ks.zustand - if was == "glattstellen": - ergebnis = ks.glattstellen("pascal", "Nachtlauf", ks_config.bestaetigungswort) - else: - ergebnis = getattr(ks, was)("pascal", "Nachtlauf") - beobachtet["klicks"].append( - f"{marktzeit.astimezone(HANDELSZONE):%H:%M} {was}: {vorher} -> {ergebnis.zustand}, " - f"erfolgreich={ergebnis.erfolgreich}, gespielte Position vorher {position}" - ) - - def pascal(): - plan = [("fortsetzen", (9, 31)), ("anhalten", (10, 30)), ("fortsetzen", (10, 36)), - ("glattstellen", (12, 0)), ("freigeben", (12, 10))] - letzte = None - glatt_um = None - while not halt.is_set(): - status = lies_status(config.status_datei) - if isinstance(status, dict): - try: - zeit = datetime.fromisoformat(status["zeit"]) - markt = datetime.fromisoformat(status["marktzeit"]) - except (KeyError, ValueError, TypeError) as ausnahme: - beobachtet["fehler"].append(repr(ausnahme)) - time.sleep(0.2) - continue - if status.get("phase") != "beendet": - alter = (datetime.now(UTC) - zeit).total_seconds() - beobachtet["max_alter_s"] = max(beobachtet["max_alter_s"], alter) - beobachtet["modi"].add(status.get("modus")) - if not beobachtet["phasen"] or beobachtet["phasen"][-1] != status.get("phase"): - beobachtet["phasen"].append(status.get("phase")) - if letzte is not None and markt < letzte: - beobachtet["rueckwaerts"] += 1 - if letzte is None or markt != letzte: - beobachtet["marktzeiten"].append(markt) - letzte = markt - ort = markt.astimezone(HANDELSZONE) - if plan and (ort.hour, ort.minute) >= plan[0][1]: - was, _ = plan.pop(0) - try: - klick(was, markt, status.get("position")) - except Exception as ausnahme: - beobachtet["klicks"].append(f"{ort:%H:%M} {was}: AUSNAHME {ausnahme!r}") - if was == "glattstellen": - glatt_um = markt - if glatt_um is not None and beobachtet["position_nach_glatt"] is None \ - and markt >= glatt_um + timedelta(minutes=2): - beobachtet["position_nach_glatt"] = status.get("position") - 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) - - print("=" * 70) - print(f"Tag {tag}, {len(kerzen)} Kerzen, Tempo {TEMPO:g} s je Marktminute, echte Dauer {dauer:.0f} s, Exit {code}") - schluss = lies_status(config.status_datei) - print(f"Statusdatei am Ende: modus={schluss.get('modus')}, phase={schluss.get('phase')}, " - f"position={schluss.get('position')}, schluessel={sorted(schluss)}") - print(f"Modi gesehen: {sorted(str(m) for m in beobachtet['modi'])}; Phasen: {beobachtet['phasen']}") - zeiten = beobachtet["marktzeiten"] - print(f"Marktzeit: {len(zeiten)} verschiedene Werte, von {zeiten[0].astimezone(HANDELSZONE):%Y-%m-%d %H:%M} " - f"bis {zeiten[-1].astimezone(HANDELSZONE):%Y-%m-%d %H:%M}, Ruecksprung {beobachtet['rueckwaerts']}") - print(f"Groesstes Alter des Herzschlags gegen die echte Uhr: {beobachtet['max_alter_s']:.1f} s") - print("Klicks:") - for zeile in beobachtet["klicks"]: - print(" " + zeile) - print(f"Gespielte Position zwei Marktminuten nach dem Glattstellen: {beobachtet['position_nach_glatt']}") - print(f"Lesefehler im Beobachter: {beobachtet['fehler'][:3]}") - - protokolle = sorted((WEG / "protokolle").glob("*.jsonl")) - zeilen = [] - for pfad in protokolle: - 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"] - arten = {} - for z in zeilen: - schluessel = z.get("art") or z.get("was") or "?" - arten[schluessel] = arten.get(schluessel, 0) + 1 - print(f"Protokoll: {[p.name for p in protokolle]}, {len(zeilen)} Zeilen, Arten {arten}") - orders = [z for z in zeilen if z.get("art") == "order" or z.get("was") == "order"] - print(f"Takte {len(takte)}, Orderzeilen {len(orders)}") - if takte: - print(f"Erster Takt: {json.dumps(takte[0], ensure_ascii=False)[:400]}") - print(f"Letzter Takt: {json.dumps(takte[-1], ensure_ascii=False)[:400]}") - - print(f"Meldungen ({len(transport.aufrufe)}):") - for m in transport.aufrufe: - print(f" {m['echt']} [{m['prioritaet']}] {m['titel']} | {m['erste_zeile']}") - 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 ueber requests: {NETZVERSUCHE}") - tagesstart = list(WEG.rglob("tagesstart.json")) - print(f"tagesstart.json im Wegwerf-Ordner: {tagesstart}") - - registry = Registry(db_pfad=WEG / "registry.db", artefakt_wurzel=ECHT / "artifacts") - ereignisse = registry.killswitch_ereignisse() - frisch = [e for e in ereignisse if getattr(e, "grund", "") and "Nachtlauf" in e.grund - or getattr(e, "ausloeser", "") in ("neustart", "paper-runner")] - print(f"Killswitch-Ereignisse im Wegwerf-Register: {len(ereignisse)} gesamt; die letzten acht:") - for e in ereignisse[-8:] if len(ereignisse) >= 8 else ereignisse: - print(f" {getattr(e, 'zeit', '?')} {e.stufe} / {e.ausloeser} / {e.grund[:60]} / erfolgreich={e.erfolgreich}") - print(f"Killswitch am Ende: {registry.killswitch_zustand().zustand}") - - befunde = [] - if code != 0: - befunde.append(f"Exit {code} statt 0") - if beobachtet["modi"] != {"generalprobe"}: - befunde.append(f"Modi {beobachtet['modi']}") - if any(not str(m["titel"]).startswith("Generalprobe: ") for m in transport.aufrufe): - befunde.append("eine Meldung ohne Vorsatz") - if not transport.aufrufe: - befunde.append("keine Meldung") - if NETZVERSUCHE: - befunde.append("Netzversuch") - if sorted(konto_der_probe.aufrufe) != ["marktuhr", "offene_orders", "positionen"]: - befunde.append("Konto der Generalprobe: andere Aufrufe als die drei lesenden") - if any(a.startswith("VERBOTEN") for a in konto_des_dashboards.aufrufe): - befunde.append("Konto des Dashboards: verbotener Aufruf") - if beobachtet["position_nach_glatt"] != 0: - befunde.append(f"Position nach dem Glattstellen {beobachtet['position_nach_glatt']}") - if beobachtet["max_alter_s"] > 5.0: - befunde.append(f"Herzschlag bis {beobachtet['max_alter_s']:.1f} s alt") - if beobachtet["rueckwaerts"]: - befunde.append("Marktzeit lief rueckwaerts") - if tagesstart: - befunde.append("tagesstart.json entstanden") - if schluss.get("position") != 0: - befunde.append(f"Position am Ende {schluss.get('position')}") - print(f"{len(befunde)} Befund(e): {befunde}") - return 2 if befunde else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/ha_aktiv_setzen.py b/.vault-tools/trading-plattform-pruefung/ha_aktiv_setzen.py deleted file mode 100644 index 252796d..0000000 --- a/.vault-tools/trading-plattform-pruefung/ha_aktiv_setzen.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Setzt [homeassistant] aktiv in config/secrets.toml auf true oder false. Gibt nie einen Wert der Datei aus. - -Aufruf: python ha_aktiv_setzen.py -Geaendert wird genau eine Zeile im Abschnitt [homeassistant]; alles andere bleibt Byte fuer Byte stehen. -""" - -import sys -import tomllib -from pathlib import Path - -pfad = Path(sys.argv[1]) -ziel = sys.argv[2] -assert ziel in ("true", "false") - -roh = pfad.read_bytes() -text = roh.decode("utf-8") -zeilenende = "\r\n" if "\r\n" in text else "\n" -zeilen = text.split(zeilenende) - -im_abschnitt = False -treffer = [] -for nummer, zeile in enumerate(zeilen): - kern = zeile.strip() - if kern.startswith("["): - im_abschnitt = kern == "[homeassistant]" - continue - if im_abschnitt and kern.split("=")[0].strip() == "aktiv": - treffer.append(nummer) - -if len(treffer) != 1: - print(f"ABBRUCH: {len(treffer)} Zeilen 'aktiv' im Abschnitt [homeassistant] gefunden, erwartet genau eine") - sys.exit(2) - -vorher = tomllib.loads(text)["homeassistant"]["aktiv"] -zeilen[treffer[0]] = f"aktiv = {ziel}" -neu = zeilenende.join(zeilen) -nachher = tomllib.loads(neu) -# Die Probe vor dem Schreiben: Die Datei bleibt gueltiges TOML, und nur dieser eine Wert aendert sich. -alt = tomllib.loads(text) -alt["homeassistant"]["aktiv"] = nachher["homeassistant"]["aktiv"] -assert alt == nachher, "mehr als ein Wert haette sich geaendert" -pfad.write_bytes(neu.encode("utf-8")) -print(f"[homeassistant] aktiv: {vorher} -> {nachher['homeassistant']['aktiv']}; Abschnitte {sorted(nachher)}; " - f"Laenge der Adresse {len(nachher['homeassistant'].get('webhook_url', ''))}") diff --git a/.vault-tools/trading-plattform-pruefung/ha_einrichten.py b/.vault-tools/trading-plattform-pruefung/ha_einrichten.py deleted file mode 100644 index 0671d9a..0000000 --- a/.vault-tools/trading-plattform-pruefung/ha_einrichten.py +++ /dev/null @@ -1,237 +0,0 @@ -"""Richtet in Home Assistant die Gegenstelle fuer die Kennzahlen der Trading Plattform ein. - -Koordinator-Werkzeug, nicht Teil des Repos. Laeuft unter WINDOWS mit dem Python des HA-Projekts -(dort liegen `websockets` und `src/ha_verbindung.py`; Adresse der Instanz und der Name der -Token-Variable stehen in jenem Modul, nicht hier). Das HA-Token bleibt auf der Windows-Seite und -wird nie ausgegeben; auf den Trading-Rechner kommt nur die Webhook-Adresse. - -Aktionen: - pruefen nur lesend: Version, vorhandene Helfer mit dem Praefix, vorhandene Automation - lesen nur lesend: wie pruefen, dazu die Zustaende der Helfer, wie sie gerade in Home Assistant stehen - einrichten legt acht Helfer und eine Automation mit Webhook-Ausloeser an (local_only, nur POST), - erzeugt dafuer eine Webhook-ID (64 Hex-Zeichen) und traegt die Adresse in die - config/secrets.toml des Trading-Projekts ein ([homeassistant], aktiv = false). - Gibt die ID nie aus. Bricht ab, wenn es den Abschnitt oder die Automation schon gibt. - probe schickt eine Beispiel-Nutzlast an den Webhook (Adresse aus der Geheimnisdatei) und - liest die Helfer ueber die HA-API zurueck - entfernen loescht Automation und Helfer wieder; der Abschnitt in der Geheimnisdatei bleibt stehen - und wird genannt - -Aufruf: \\.venv\\Scripts\\python.exe ha_einrichten.py -""" - -import json -import os -import secrets -import sys -import time -import tomllib -import urllib.request -from pathlib import Path - -AUTOMATION_ID = "trading_plattform_kennzahlen" -ALIAS = "Trading Plattform: Kennzahlen uebernehmen" -PRAEFIX = "trading_" - -TEXTE = ("Trading Phase", "Trading Modus", "Trading Killswitch") -ZAHLEN = ( - ("Trading Position", -100000, 100000, 1, "Stk"), - ("Trading Tagesergebnis USD", -1000000, 1000000, 0.01, "USD"), - ("Trading Herzschlag Alter s", -1, 100000000, 1, "s"), -) -SCHALTER = ("Trading Veraltet",) -ZEITEN = ("Trading Gesendet",) - - -def aktionen() -> list[dict]: - def text(ziel: str, schluessel: str, vorgabe: str) -> dict: - return { - "service": "input_text.set_value", - "target": {"entity_id": f"input_text.{ziel}"}, - "data": {"value": "{{ (trigger.json." + schluessel + " | default('" + vorgabe + "') | string)[:100] }}"}, - "continue_on_error": True, - } - - def zahl(ziel: str, schluessel: str, filter_: str) -> dict: - return { - "service": "input_number.set_value", - "target": {"entity_id": f"input_number.{ziel}"}, - "data": {"value": "{{ trigger.json." + schluessel + " | default(0) | " + filter_ + " }}"}, - "continue_on_error": True, - } - - return [ - text("trading_phase", "phase", "unbekannt"), - text("trading_modus", "modus", ""), - text("trading_killswitch", "killswitch", "unbekannt"), - zahl("trading_position", "position", "int(0)"), - zahl("trading_tagesergebnis_usd", "tagesergebnis_usd", "float(0) | round(2)"), - zahl("trading_herzschlag_alter_s", "herzschlag_alter_s", "int(-1)"), - { - "service": "input_boolean.turn_{{ 'on' if (trigger.json.veraltet | default(false)) else 'off' }}", - "target": {"entity_id": "input_boolean.trading_veraltet"}, - "continue_on_error": True, - }, - { - "service": "input_datetime.set_datetime", - "target": {"entity_id": "input_datetime.trading_gesendet"}, - "data": {"timestamp": "{{ now().timestamp() }}"}, - "continue_on_error": True, - }, - ] - - -def lies_abschnitt(datei: Path) -> dict | None: - return tomllib.loads(datei.read_text(encoding="utf-8")).get("homeassistant") - - -def trage_ein(datei: Path, adresse: str) -> None: - roh = datei.read_text(encoding="utf-8") - vorher = tomllib.loads(roh) - block = ( - "\n[homeassistant]\n" - "# Webhook in Home Assistant fuer die Kennzahlen des Paper-Runners. Die ID in der Adresse ist das\n" - "# Geheimnis: Wer sie kennt, kann die Anzeige in HA faelschen. Eingeschaltet wird mit aktiv = true.\n" - f'webhook_url = "{adresse}"\n' - "aktiv = false\n" - ) - neu = roh.rstrip("\n") + "\n" + block - nachher = tomllib.loads(neu) - if {k: v for k, v in nachher.items() if k != "homeassistant"} != vorher: - raise SystemExit("ABBRUCH: bestehende Abschnitte haetten sich geaendert") - nachbar = datei.with_name(datei.name + ".neu") - with open(nachbar, "w", encoding="utf-8", newline="\n") as strom: - strom.write(neu) - os.replace(nachbar, datei) - - -def helfer_liste(ha) -> dict[str, list[dict]]: - ergebnis = {} - for art in ("input_text", "input_number", "input_boolean", "input_datetime"): - eintraege = ha.befehl_ok(f"{art}/list") or [] - ergebnis[art] = [e for e in eintraege if str(e.get("id", "")).startswith(PRAEFIX)] - return ergebnis - - -def main() -> int: - aktion, ha_projekt, trading = sys.argv[1], Path(sys.argv[2]), Path(sys.argv[3]) - sys.path.insert(0, str(ha_projekt / "src")) - import ha_verbindung as hv - - # Seit E2 (2026-09-23): der Aufloeser des Projekts entscheidet -- TRADING_GEHEIMNISSE, sonst - # config/secrets.toml relativ zum uebergebenen Projektordner. - sys.path.insert(0, str(trading)) - from trading import geheimnisse as aufloeser # noqa: E402 - - aufgeloest = aufloeser.pfad() - geheimnisse = aufgeloest if aufgeloest.is_absolute() else trading / aufgeloest - token = hv.lies_token() - print(f"Token gelesen (Laenge {len(token)}), Instanz aus dem HA-Projekt, Aktion {aktion}") - - # nur_lesen=False auch fuers Pruefen: Die Leseliste jenes Moduls kennt die Listenbefehle der - # Helfer und get_states nicht. 'pruefen' und 'probe' senden trotzdem nur lesende Befehle. - with hv.HaVerbindung(hv.BASIS_URL, token, nur_lesen=False) as ha: - vorhanden = helfer_liste(ha) - zahl_helfer = sum(len(v) for v in vorhanden.values()) - automationen = [ - s for s in (ha.befehl_ok("get_states") or []) - if s.get("entity_id", "").startswith("automation.") and s.get("attributes", {}).get("id") == AUTOMATION_ID - ] - abschnitt = lies_abschnitt(geheimnisse) - print(f"Helfer mit Praefix {PRAEFIX}: {zahl_helfer} -- " + ", ".join(f"{a}: {[e['id'] for e in v]}" for a, v in vorhanden.items() if v)) - print(f"Automation {AUTOMATION_ID}: {'vorhanden' if automationen else 'fehlt'}") - print("[homeassistant] in der Geheimnisdatei: " + ("fehlt" if abschnitt is None else str({k: (type(v).__name__, len(v) if isinstance(v, str) else v) for k, v in abschnitt.items()}))) - - if aktion == "pruefen": - return 0 - - if aktion == "lesen": - # Nur lesen: die Zustaende der Helfer, wie sie gerade in Home Assistant stehen. - zustaende = {s["entity_id"]: s["state"] for s in (ha.befehl_ok("get_states") or []) if f".{PRAEFIX}" in s.get("entity_id", "")} - for name in sorted(zustaende): - print(f" {name} = {zustaende[name]}") - return 0 - - if aktion == "einrichten": - if abschnitt is not None or automationen or zahl_helfer: - print("ABBRUCH: Es gibt schon Teile der Einrichtung; erst 'entfernen' oder von Hand klaeren.") - return 3 - for name in TEXTE: - ha.befehl_ok("input_text/create", name=name, min=0, max=100, mode="text", icon="mdi:chart-line") - for name, kleinst, groesst, schritt, einheit in ZAHLEN: - ha.befehl_ok("input_number/create", name=name, min=kleinst, max=groesst, step=schritt, mode="box", unit_of_measurement=einheit, icon="mdi:chart-line") - for name in SCHALTER: - ha.befehl_ok("input_boolean/create", name=name, icon="mdi:alert-outline") - for name in ZEITEN: - ha.befehl_ok("input_datetime/create", name=name, has_date=True, has_time=True, icon="mdi:clock-outline") - webhook_id = secrets.token_hex(32) - status, antwort = ha.http_post( - f"/api/config/automation/config/{AUTOMATION_ID}", - { - "alias": ALIAS, - "description": "Uebernimmt Phase, Killswitch, Position und Tagesergebnis des Paper-Runners aus dem Webhook der Trading Plattform. Angelegt am 2026-09-20 ueber ha_einrichten.py; Rueckbau mit der Aktion entfernen.", - "trigger": [{"platform": "webhook", "webhook_id": webhook_id, "allowed_methods": ["POST"], "local_only": True}], - "condition": [], - "action": aktionen(), - "mode": "queued", - "max": 5, - }, - ) - print(f"Automation angelegt: HTTP {status}") - if status != 200: - print("ABBRUCH: Die Automation wurde nicht angenommen:", str(antwort)[:200].replace(webhook_id, "***")) - return 2 - trage_ein(geheimnisse, f"{hv.BASIS_URL}/api/webhook/{webhook_id}") - print("Adresse eingetragen ([homeassistant], aktiv = false); die ID wurde nicht ausgegeben.") - return 0 - - if aktion == "probe": - if abschnitt is None: - print("ABBRUCH: kein [homeassistant] in der Geheimnisdatei") - return 3 - nutzlast = { - "phase": "probe_der_einrichtung", "modus": "probe", "killswitch": "angehalten", "position": 7, - "tagesergebnis_usd": -12.34, "herzschlag_alter_s": 5, "veraltet": False, - "gesendet": time.strftime("%Y-%m-%dT%H:%M:%S"), - } - anfrage = urllib.request.Request( - abschnitt["webhook_url"], data=json.dumps(nutzlast).encode("utf-8"), - headers={"Content-Type": "application/json"}, method="POST", - ) - try: - with urllib.request.urlopen(anfrage, timeout=5) as antwort: - print(f"Webhook: HTTP {antwort.status}") - except Exception as ausnahme: - # Nur der Typ: Die Ausnahme traegt die Adresse und damit die ID. - print(f"Webhook GESCHEITERT: {type(ausnahme).__name__}") - return 2 - time.sleep(2.0) - zustaende = {s["entity_id"]: s["state"] for s in (ha.befehl_ok("get_states") or []) if f".{PRAEFIX}" in s.get("entity_id", "")} - for name in sorted(zustaende): - print(f" {name} = {zustaende[name]}") - erwartet = { - "input_text.trading_phase": "probe_der_einrichtung", "input_text.trading_killswitch": "angehalten", - "input_number.trading_position": "7.0", "input_number.trading_tagesergebnis_usd": "-12.34", - "input_boolean.trading_veraltet": "off", - } - falsch = {k: (v, zustaende.get(k)) for k, v in erwartet.items() if zustaende.get(k) != v} - print("Rueckgelesen wie gesendet." if not falsch else f"BEFUND: {falsch}") - return 0 if not falsch else 1 - - if aktion == "entfernen": - status = ha.http_delete(f"/api/config/automation/config/{AUTOMATION_ID}") - print(f"Automation geloescht: HTTP {status}") - for art, eintraege in vorhanden.items(): - for eintrag in eintraege: - ha.befehl_ok(f"{art}/delete", **{f"{art}_id": eintrag["id"]}) - print(f" geloescht: {art}.{eintrag['id']}") - if abschnitt is not None: - print("Hinweis: [homeassistant] steht weiter in der Geheimnisdatei des Trading-Projekts; von Hand entfernen.") - return 0 - - print("unbekannte Aktion") - return 2 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/ha_totmann.py b/.vault-tools/trading-plattform-pruefung/ha_totmann.py deleted file mode 100644 index 1a67ade..0000000 --- a/.vault-tools/trading-plattform-pruefung/ha_totmann.py +++ /dev/null @@ -1,929 +0,0 @@ -"""Richtet in Home Assistant den Totmann der Trading Plattform ein (Paket Z2, Anforderung Nr. 7). - -Koordinator-Werkzeug, nicht Teil des Repos. Zwei Teile: -- dieses Skript laeuft unter WINDOWS mit dem Python des HA-Projekts (dort liegen websockets, - requests, src/ha_verbindung.py, src/config_datei.py und src/vorlage_auswerten.py; Adresse - der Instanz und Name der Token-Variable stehen dort, nicht hier) und schreibt nach Home - Assistant; -- ha_totmann_wsl.py laeuft in WSL mit dem Python der Trading Plattform, liest dort die - Geheimnisdatei und den Kalender von Alpaca und gibt Thema, Token und Kalender als eine - JSON-Zeile zurueck. Dieses Skript liest sie nur als Pipe (aus_wsl) und gibt davon nichts - aus, nur Laengen. Die Geheimnisdatei verlaesst WSL nie. -Nichts in diesen Dateien ist ein Geheimnis. Thema und Token landen nur in secrets.yaml von -Home Assistant. - -Was der Totmann tut: Eine Automation prueft alle fuenf Minuten, ob das Lebenszeichen der -Plattform (input_datetime.trading_gesendet; der Hausmelder schickt es spaetestens alle fuenf -Minuten) aelter als 15 Minuten ist -- nur an Handelstagen, von 15 Minuten nach der Oeffnung -bis zum Schluss. Dann meldet sie einmal je Stille und Handelstag ueber die HA-App (der Dienst -ist das Argument --app-dienst, kein Literal hier) und ueber ntfy (rest_command, Adresse und -Zugriff aus der secrets.yaml von Home Assistant). - -Die Bedingung besteht aus vier Eingaben und einer Entscheidung (ENTSCHEIDUNG). Die Automation -setzt die Eingaben live (EINGABEN_LIVE); trocken rechnet die Entscheidung mit gestellten -Lagen (faelle) lokal, pruefen laesst Home Assistant dieselben Lagen per render_template -rechnen. So ist belegt, dass Home Assistant die Entscheidung so versteht wie gemeint. - -Handelstage und Fenster kommen beim Einrichten aus dem Kalender von Alpaca (ueber den -WSL-Teil) fuer die naechsten --tage Tage und stehen als Epoch-Sekunden in der Bedingung. Nach -dem letzten Datum schweigt der Totmann; der Alias nennt es, "einrichten" verlaengert. Die New -Yorker Zeit rechnet das Werkzeug selbst (_ny_versatz_h), weil Python unter Windows ohne das -Paket tzdata keine Zeitzonen kennt. - -Schreibwege nach /config: secrets.yaml und configuration.yaml ueber die API des -File-editor-Add-ons (src/config_datei.py des HA-Projekts; dessen Pfadregel erlaubt nur -themes/, dieses Werkzeug ersetzt sie zur Laufzeit fuer genau diese zwei Dateien). Jeder -eigene Block steht zwischen MARKE_ANFANG und MARKE_ENDE; "entfernen" nimmt genau diese -Bloecke wieder heraus. Die Automation geht ueber /api/config/automation/config/, wie in -ha_einrichten.py. - -Aktionen: - pruefen lesend: Dienste, Bloecke (nur ja oder nein), Automation, Alter des - Lebenszeichens, die Eingaben heute/jetzt/gesendet live, die juengste Spur der - Automation (ob die Bedingung mit this ohne Fehler rechnet) und die Rechenprobe - der Bedingung in Home Assistant - trocken ohne Netz: baut Automation und Bloecke aus erfundenen Werten, rechnet die - gestellten Lagen lokal und prueft die Regeln des WSL-Teils - einrichten holt Thema, Token und Kalender aus WSL, schreibt die Bloecke (wenn noetig), - prueft die Konfiguration, laedt rest_command neu oder verlangt einen Neustart - von Home Assistant (Exit 4) und legt die Automation an oder ersetzt sie - probe ruft rest_command und den App-Dienst je einmal -- zwei echte Meldungen - entfernen loescht die Automation und nimmt beide Bloecke heraus - -Aufruf: \\.venv\\Scripts\\python.exe ha_totmann.py [ - ] [--app-dienst notify.] [--tage 400] -Exit: 0 fertig, 2 Abbruch oder BEFUND, 3 Voraussetzung fehlt, 4 Neustart von HA noetig. -""" - -import argparse -import json -import re -import subprocess -import sys -import time -import tomllib -from datetime import date -from datetime import datetime -from datetime import timedelta -from datetime import timezone -from pathlib import Path -from pathlib import PureWindowsPath - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -import ha_totmann_wsl as wsl_teil # noqa: E402 - -AUTOMATION_ID = "trading_plattform_totmann" -ALIAS = "Trading Plattform: Totmann" -LEBENSZEICHEN = "input_datetime.trading_gesendet" -REST_DIENST = "trading_totmann_ntfy" -SECRET_URL = "trading_totmann_ntfy_url" -SECRET_AUTH = "trading_totmann_ntfy_auth" -MARKE_ANFANG = "# >>> trading-totmann (ha_totmann.py, Paket Z2)" -MARKE_ENDE = "# <<< trading-totmann" -ERLAUBT = ("secrets.yaml", "configuration.yaml") -STILLE_S = 900 -# Wie weit die Uhr von Home Assistant von der dieses Rechners abweichen darf, bevor -# eingaben_befunde es als Befund nennt. -UHR_TOLERANZ_S = 120 -PRUEFTAKT = "/5" -APP_DIENST_MUSTER = re.compile(r"^notify\.[a-z0-9_]+$") -FREMDES_REST_COMMAND = re.compile(r"(?m)^rest_command:") -WSL = r"C:\Windows\System32\wsl.exe" -WSL_PY = "/home/claude/venvs/trading/bin/python" -WSL_TEIL = Path(__file__).resolve().with_name("ha_totmann_wsl.py") -WSL_TIMEOUT_S = 120 -TEXT = ( - "Die Trading Plattform schweigt im Handelsfenster seit mehr als 15 Minuten. Letztes " - "Lebenszeichen: {{ states('" + LEBENSZEICHEN + "') }}. Nachsehen: Rechner, WSL, Dashboard." -) -PROBE_TEXT = "Probe des Totmanns (ha_totmann.py probe): Diese Meldung kam an. Nichts zu tun." -# Erfundene Werte fuer trocken. Kein echtes Thema, kein echter Dienst, kein echter Token. -TROCKEN_THEMA = "probe-thema-nicht-echt" -TROCKEN_TOKEN = "tk_probe_nicht_echt" -TROCKEN_DIENST = "notify.probe_dienst" - -# Die vier Eingaben der Bedingung, wie die Automation sie liest. this ist die Automation -# selbst; ihr last_triggered ist die letzte Ausloesung. -EINGABEN_LIVE = { - "heute": "now().strftime('%Y-%m-%d')", - "jetzt": "now().timestamp()", - "gesendet": "state_attr('" + LEBENSZEICHEN + "', 'timestamp') | float(0)", - "zuletzt": "as_timestamp(this.attributes.last_triggered, 0)", -} -# Die Entscheidung, gemeinsam fuer Automation, trocken und pruefen. Ausloesen, wenn heute -# ein Handelstag ist, jetzt im Fenster liegt, das Lebenszeichen aelter als STILLE_S ist und -# die letzte Ausloesung vor dem Lebenszeichen lag -- oder vor dem heutigen Fenster: Haelt -# eine Stille ueber Nacht an, meldet der neue Handelstag sie einmal. -ENTSCHEIDUNG = ( - "f is not none and f[0] <= jetzt <= f[1] and jetzt - gesendet > " - + str(STILLE_S) - + " and (zuletzt <= gesendet or zuletzt < f[0])" -) -# Ein erfundener Kalender fuer trocken und die Rechenprobe in pruefen: zwei gewoehnliche Tage -# und der verkuerzte Tag nach Thanksgiving 2026. -PROBE_KALENDER = [ - {"date": "2026-09-28", "open": "09:30", "close": "16:00"}, - {"date": "2026-09-29", "open": "09:30", "close": "16:00"}, - {"date": "2026-11-27", "open": "09:30", "close": "13:00"}, -] - - -class Voraussetzung(Exception): - """Etwas fehlt fuer den Lauf. Der Text traegt keinen Wert aus der Geheimnisdatei.""" - - -def _ny_versatz_h(tag: date) -> int: - """UTC-Versatz der New Yorker Boersenzeit an einem Tag: -4 in der Sommerzeit, sonst -5. - - US-Regel seit 2007: vom zweiten Sonntag im Maerz bis zum ersten Sonntag im November. - Umgestellt wird sonntags um 02:00; an einem Handelstag gilt der Versatz des ganzen Tages. - """ - maerz = date(tag.year, 3, 1) - zweiter_sonntag_maerz = maerz + timedelta(days=(6 - maerz.weekday()) % 7 + 7) - november = date(tag.year, 11, 1) - erster_sonntag_november = november + timedelta(days=(6 - november.weekday()) % 7) - return -4 if zweiter_sonntag_maerz <= tag < erster_sonntag_november else -5 - - -def fenster_je_tag(kalender: list[dict]) -> dict[str, list[int]]: - """Je Handelstag [Oeffnung plus STILLE_S, Schluss] als Epoch-Sekunden.""" - fenster: dict[str, list[int]] = {} - for eintrag in kalender: - tag = date.fromisoformat(eintrag["date"]) - zone = timezone(timedelta(hours=_ny_versatz_h(tag))) - grenzen = [] - for feld in ("open", "close"): - stunde, minute = (int(teil) for teil in eintrag[feld].split(":")) - grenzen.append( - int(datetime(tag.year, tag.month, tag.day, stunde, minute, tzinfo=zone).timestamp()) - ) - fenster[tag.isoformat()] = [grenzen[0] + STILLE_S, grenzen[1]] - return fenster - - -def gestellt(heute: str, jetzt: int, gesendet: int, zuletzt: int) -> dict[str, str]: - """Gestellte Eingaben als Vorlagen-Literale, fuer die Rechenprobe in pruefen.""" - return { - "heute": "'" + heute + "'", - "jetzt": str(jetzt), - "gesendet": str(gesendet), - "zuletzt": str(zuletzt), - } - - -def bedingung(fenster: dict[str, list[int]], eingaben: dict[str, str] | None = None) -> str: - """Die Vorlage der Bedingung; ohne eingaben die der Automation (EINGABEN_LIVE).""" - werte = EINGABEN_LIVE if eingaben is None else eingaben - return ( - "{% set fenster = " + json.dumps(fenster, separators=(",", ":")) + " %}" - + "".join( - "{% set " + name + " = " + werte[name] + " %}" - for name in ("heute", "jetzt", "gesendet", "zuletzt") - ) - + "{% set f = fenster.get(heute) %}" - + "{{ " + ENTSCHEIDUNG + " }}" - ) - - -def _ohne_eingaben(vorlage: str) -> str: - return re.sub(r"\{% set (heute|jetzt|gesendet|zuletzt) = .*? %\}", "", vorlage) - - -def _lage(heute: str, jetzt: int, gesendet: int, zuletzt: int) -> dict: - return {"heute": heute, "jetzt": jetzt, "gesendet": gesendet, "zuletzt": zuletzt} - - -def faelle(fenster: dict[str, list[int]]) -> list[tuple[str, dict, bool]]: - """Gestellte Lagen: Name, Eingaben, ob die Bedingung ausloesen soll. - - Montag 28.09. und Dienstag 29.09.2026 aus PROBE_KALENDER, alle Zeiten erfunden. mitte - liegt eine Stunde nach dem Beginn des Montagsfensters. - """ - montag, dienstag = fenster["2026-09-28"], fenster["2026-09-29"] - mitte = montag[0] + 3600 - return [ - ("stille_im_fenster", _lage("2026-09-28", mitte, mitte - 1800, 0), True), - ("lebenszeichen_frisch", _lage("2026-09-28", mitte, mitte - 300, 0), False), - ( - "schon_gemeldet_nach_dem_letzten_lebenszeichen", - _lage("2026-09-28", mitte, mitte - 1800, mitte - 600), - False, - ), - ("vor_dem_fenster", _lage("2026-09-28", montag[0] - 60, montag[0] - 7200, 0), False), - ("nach_dem_schluss", _lage("2026-09-28", montag[1] + 600, mitte, 0), False), - ("kein_handelstag", _lage("2026-09-27", mitte - 86400, 0, 0), False), - ( - "stille_ueber_die_tagesgrenze", - _lage("2026-09-29", dienstag[0] + 600, mitte - 1800, mitte - 600), - True, - ), - ( - "am_neuen_tag_schon_gemeldet", - _lage("2026-09-29", dienstag[0] + 1200, mitte - 1800, dienstag[0] + 600), - False, - ), - ] - - -def _entscheide_lokal(fenster: dict[str, list[int]], lage: dict) -> bool: - # ENTSCHEIDUNG ist eine Konstante dieser Datei, kein fremder Text; eval ohne Builtins, - # none steht fuer das None der Vorlagensprache. - return bool( - eval( # noqa: S307 - ENTSCHEIDUNG, - {"__builtins__": {}}, - { - "none": None, - "f": fenster.get(lage["heute"]), - "jetzt": lage["jetzt"], - "gesendet": lage["gesendet"], - "zuletzt": lage["zuletzt"], - }, - ) - ) - - -def _wahrheit(wert: object) -> bool | None: - if isinstance(wert, bool): - return wert - if isinstance(wert, str) and wert.strip().lower() in ("true", "false"): - return wert.strip().lower() == "true" - return None - - -def automation(fenster: dict[str, list[int]], app_dienst: str) -> dict: - letzter = max(fenster) if fenster else "keiner" - return { - "alias": f"{ALIAS} (Handelstage bis {letzter})", - "description": ( - "Meldet ueber die HA-App und ntfy, wenn das Lebenszeichen der Trading Plattform " - f"({LEBENSZEICHEN}) im Handelsfenster aelter als 15 Minuten ist; einmal je " - f"Stille und Handelstag. Handelstage aus dem Kalender von Alpaca bis {letzter}. " - "Angelegt ueber ha_totmann.py (Paket Z2); Rueckbau mit der Aktion entfernen." - ), - "trigger": [{"platform": "time_pattern", "minutes": PRUEFTAKT}], - "condition": [{"condition": "template", "value_template": bedingung(fenster)}], - "action": [ - { - "service": f"rest_command.{REST_DIENST}", - "data": {"nachricht": TEXT}, - "continue_on_error": True, - }, - { - "service": app_dienst, - "data": {"title": "Trading Totmann", "message": TEXT}, - "continue_on_error": True, - }, - ], - "mode": "single", - "max_exceeded": "silent", - } - - -def config_block(mit_token: bool) -> str: - """Der Block fuer configuration.yaml. Kein Thema: Adresse und Zugriff kommen per !secret.""" - zeilen = [ - MARKE_ANFANG, - "# Totmann der Trading Plattform: Meldung ueber ntfy. Adresse und Zugriff stehen in", - "# secrets.yaml; hier steht kein Thema.", - "rest_command:", - f" {REST_DIENST}:", - f" url: !secret {SECRET_URL}", - " method: POST", - ' content_type: "text/plain; charset=utf-8"', - " timeout: 10", - ' payload: "{{ nachricht }}"', - " headers:", - ' Title: "Trading Totmann"', - ' Priority: "high"', - ' Tags: "rotating_light"', - ] - if mit_token: - zeilen.append(f" Authorization: !secret {SECRET_AUTH}") - zeilen.append(MARKE_ENDE) - return "\n".join(zeilen) + "\n" - - -def secrets_block(url: str, token: str) -> str: - """Der Block fuer secrets.yaml: die ganze Adresse mit Thema, dazu der Zugriff.""" - zeilen = [MARKE_ANFANG, f'{SECRET_URL}: "{url}"'] - if token: - zeilen.append(f'{SECRET_AUTH}: "Bearer {token}"') - zeilen.append(MARKE_ENDE) - return "\n".join(zeilen) + "\n" - - -def ohne_block(text: str) -> str: - """Der Text ohne den eigenen Block zwischen den Marken, mit genau einem Umbruch am Ende.""" - muster = re.compile( - r"\n*" + re.escape(MARKE_ANFANG) + r".*?" + re.escape(MARKE_ENDE) + r"\n?", re.DOTALL - ) - rest = muster.sub("\n", text).rstrip("\n") - return rest + "\n" if rest else "" - - -def anhaengen(text: str, block: str) -> str: - rest = text.rstrip("\n") - return (rest + "\n\n" if rest else "") + block - - -def _ohne(text: str, *geheim: str) -> str: - for wert in geheim: - if wert: - text = text.replace(wert, "***") - return text - - -def _wsl_pfad(pfad: str) -> str: - """Ein Windows-Pfad mit Laufwerksbuchstaben als Pfad unter /mnt in WSL.""" - windows = PureWindowsPath(pfad) - if len(windows.drive) != 2 or not windows.drive.endswith(":"): - raise Voraussetzung(f"kein Laufwerkspfad: {pfad}") - return "/mnt/" + windows.drive[0].lower() + "/" + "/".join(windows.parts[1:]) - - -def pruefe_wsl_ausgabe(daten: object) -> dict: - """Die Zeile des WSL-Teils, nach denselben Regeln geprueft, die er selbst anlegt.""" - if not isinstance(daten, dict) or set(daten) != {"thema", "token", "kalender"}: - raise Voraussetzung("WSL-Teil: Ausgabe in unerwarteter Form") - thema, token = wsl_teil.zugang({"ntfy": {"thema": daten["thema"], "token": daten["token"]}}) - return {"thema": thema, "token": token, "kalender": wsl_teil.pruefe_kalender(daten["kalender"])} - - -def aus_wsl(tage: int) -> dict: - """Startet den WSL-Teil und liest seine eine Zeile als Pipe. stdout wird nie ausgegeben.""" - lauf = subprocess.run( - [ - WSL, "-d", "Debian", "-u", "claude", "--exec", - WSL_PY, _wsl_pfad(str(WSL_TEIL)), "--tage", str(tage), - ], - capture_output=True, - timeout=WSL_TIMEOUT_S, - ) - if lauf.returncode != 0: - # stderr des WSL-Teils traegt nur Befunde ohne Werte. - fehler = lauf.stderr.decode("utf-8", errors="replace").replace("\x00", "").strip() - raise Voraussetzung( - f"WSL-Teil endete mit Exit {lauf.returncode}: {fehler[:300] or 'ohne Meldung'}" - ) - zeilen = [ - zeile - for zeile in lauf.stdout.decode("utf-8", errors="replace").split("\n") - if zeile.startswith("{") - ] - if len(zeilen) != 1: - raise Voraussetzung(f"WSL-Teil: {len(zeilen)} statt genau einer JSON-Zeile") - return pruefe_wsl_ausgabe(json.loads(zeilen[0])) - - -def trocken() -> int: - """Prueft die eigenen Bausteine ohne Netz, mit erfundenen Werten.""" - befunde = [] - for tag, erwartet in ( - (date(2026, 3, 6), -5), - (date(2026, 3, 9), -4), - (date(2026, 10, 30), -4), - (date(2026, 11, 2), -5), - (date(2027, 3, 12), -5), - (date(2027, 3, 15), -4), - ): - if _ny_versatz_h(tag) != erwartet: - befunde.append(f"Versatz am {tag}: {_ny_versatz_h(tag)} statt {erwartet}") - fenster = fenster_je_tag(PROBE_KALENDER) - # 09:45 EDT ist 13:45 UTC, 16:00 EDT 20:00 UTC; 09:45 EST ist 14:45 UTC, 13:00 EST 18:00 UTC. - for tag, soll in (("2026-09-28", ["13:45", "20:00"]), ("2026-11-27", ["14:45", "18:00"])): - ist = [datetime.fromtimestamp(s, timezone.utc).strftime("%H:%M") for s in fenster[tag]] - if ist != soll: - befunde.append(f"Fenster am {tag}: {ist} statt {soll}") - # Die Bedingung: jede gestellte Lage lokal, mit derselben ENTSCHEIDUNG wie in HA. - lagen = faelle(fenster) - for name, lage, soll in lagen: - if _entscheide_lokal(fenster, lage) is not soll: - befunde.append(f"Bedingung {name}: soll {'ausloesen' if soll else 'schweigen'}") - live = bedingung(fenster) - for name, ausdruck in EINGABEN_LIVE.items(): - if "{% set " + name + " = " + ausdruck + " %}" not in live: - befunde.append(f"die Automation liest {name} nicht wie vorgesehen") - if _ohne_eingaben(live) != _ohne_eingaben(bedingung(fenster, gestellt(**lagen[0][1]))): - befunde.append("die Rechenprobe entschiede mit einem anderen Ausdruck als die Automation") - roh = json.dumps(automation(fenster, TROCKEN_DIENST)) - anders = json.dumps(automation(fenster, "notify.anderer_dienst")) - konf = config_block(True) - geheim = secrets_block("https://ntfy.example/" + TROCKEN_THEMA, TROCKEN_TOKEN) - if TROCKEN_THEMA in roh or TROCKEN_THEMA in konf: - befunde.append("das Thema stuende in der Automation oder in configuration.yaml") - if TROCKEN_TOKEN in roh or TROCKEN_TOKEN in konf: - befunde.append("der Token stuende in der Automation oder in configuration.yaml") - if TROCKEN_THEMA not in geheim or f"Bearer {TROCKEN_TOKEN}" not in geheim: - befunde.append("Thema oder Token fehlen im Block fuer secrets.yaml") - if roh.count(TROCKEN_DIENST) != 1 or "notify.anderer_dienst" not in anders: - befunde.append("der App-Dienst kommt nicht aus dem Argument") - if TROCKEN_DIENST in anders: - befunde.append("der App-Dienst steht fest in der Automation") - if f"!secret {SECRET_URL}" not in konf or f"!secret {SECRET_AUTH}" not in konf: - befunde.append("configuration.yaml nennt die Geheimnisse nicht per !secret") - if f"!secret {SECRET_AUTH}" in config_block(False): - befunde.append("ohne Token verwiese configuration.yaml auf ein fehlendes Geheimnis") - if ohne_block(anhaengen("a: 1\n", konf)) != "a: 1\n": - befunde.append("entfernen liesse vom Block etwas stehen") - if ohne_block(konf) != "": - befunde.append("eine Datei nur mit dem Block bliebe nicht leer") - if not FREMDES_REST_COMMAND.search(ohne_block("x: 1\nrest_command:\n y: 2\n")): - befunde.append("ein fremdes rest_command wuerde uebersehen") - if FREMDES_REST_COMMAND.search(ohne_block(anhaengen("x: 1\n", konf))): - befunde.append("der eigene Block gaelte als fremdes rest_command") - # Der WSL-Teil, ohne ihn zu starten: seine Regeln und der Weg ueber die Pipe. - if _wsl_pfad(r"D:\Claude\.vault-tools\x\y.py") != "/mnt/d/Claude/.vault-tools/x/y.py": - befunde.append("der Pfad fuer WSL ist falsch umgesetzt") - try: - wsl_teil.zugang({"ntfy": {"thema": "kein thema!", "token": ""}}) - befunde.append("ein Thema ausserhalb der Regel ginge durch") - except wsl_teil.Befund as befund: - if "kein thema" in str(befund): - befunde.append("der Befund des WSL-Teils nennt den Wert") - try: - wsl_teil.pruefe_kalender([{"date": "2026-09-28", "open": "09:30", "close": "4pm"}]) - befunde.append("ein Kalender mit falscher Uhrzeit ginge durch") - except wsl_teil.Befund: - pass - try: - zurueck = pruefe_wsl_ausgabe( - json.loads(wsl_teil.ausgabe(TROCKEN_THEMA, TROCKEN_TOKEN, PROBE_KALENDER)) - ) - if zurueck != {"thema": TROCKEN_THEMA, "token": TROCKEN_TOKEN, "kalender": PROBE_KALENDER}: - befunde.append("die Zeile des WSL-Teils kommt nicht unveraendert an") - except (Voraussetzung, wsl_teil.Befund) as fehler: - befunde.append(f"die Zeile des WSL-Teils wird abgelehnt: {fehler}") - try: - pruefe_wsl_ausgabe({"thema": TROCKEN_THEMA}) - befunde.append("eine unvollstaendige Zeile des WSL-Teils ginge durch") - except Voraussetzung: - pass - # Die Plausibilitaet der Live-Eingaben, mit gestellten Antworten: Mittag in Berlin. - jetzt_probe = datetime(2026, 9, 28, 10, 0, tzinfo=timezone.utc).timestamp() - gut = { - "heute": datetime.fromtimestamp(jetzt_probe).strftime("%Y-%m-%d"), - "jetzt": jetzt_probe, - "gesendet": jetzt_probe - 100, - } - if eingaben_befunde(gut, jetzt_probe - 100, jetzt_probe): - befunde.append("plausible Live-Eingaben gaelten als Befund") - for name, schlecht, zeichen in ( - ("gesendet_null", dict(gut, gesendet=0.0), None), - ("uhr_daneben", dict(gut, jetzt=jetzt_probe + 600), jetzt_probe - 100), - ("falscher_tag", dict(gut, heute="2026-09-27"), jetzt_probe - 100), - ("anderer_stempel", gut, jetzt_probe - 5000), - ("jetzt_kein_wert", dict(gut, jetzt=None), jetzt_probe - 100), - ): - if not eingaben_befunde(schlecht, zeichen, jetzt_probe): - befunde.append(f"Live-Eingaben {name} gingen durch") - # Die Spur, mit gestellten Antworten von trace/list und trace/get. - in_ordnung = {"timestamp": {"start": "x"}, "trace": {"condition/0": [{"result": {"result": False}}]}} - for name, liste, lauf, soll in ( - ("ohne_lauf", [], None, False), - ("in_ordnung", [{"run_id": "1"}], in_ordnung, False), - ("renderfehler", [{"run_id": "1"}], {"trace": {"condition/0": [{"error": "UndefinedError"}]}}, True), - ("ohne_bedingung", [{"run_id": "1"}], {"trace": {}}, True), - ("ohne_wahrheitswert", [{"run_id": "1"}], {"trace": {"condition/0": [{"result": {}}]}}, True), - ): - if spur_befund(liste, lauf)[1] is not soll: - befunde.append(f"Spur {name}: soll {'Befund' if soll else 'kein Befund'}") - for befund in befunde: - print(f"BEFUND {befund}") - print( - f"trocken: {len(fenster)} Tage, {len(lagen)} Lagen, Automation {len(roh)} Zeichen, " - f"{len(befunde)} Befund(e)" - ) - return 2 if befunde else 0 - - -def _nur_zwei_dateien(pfad: object, *, datei: bool) -> str: - """Ersetzt config_datei.pruefe_pfad fuer dieses Werkzeug: genau zwei Dateien in /config.""" - from config_datei import PfadVerboten - - if pfad not in ERLAUBT: - raise PfadVerboten(f"Pfad {pfad!r} ist keine der Dateien {ERLAUBT}") - return str(pfad) - - -def _dienste(ha) -> dict: - return ha.befehl_ok("get_services") or {} - - -def bedingung_pruefen(ha) -> int: - """Die Rechenprobe: Home Assistant rechnet jede gestellte Lage per render_template. - - Lesend (render_template steht in der Leseliste des HA-Projekts). Gerechnet wird mit dem - erfundenen PROBE_KALENDER und derselben ENTSCHEIDUNG wie in der Automation; nur die vier - Eingaben sind gestellt. Zurueck kommt die Zahl der Befunde. - """ - from vorlage_auswerten import auswerten - - fenster = fenster_je_tag(PROBE_KALENDER) - befunde = 0 - for name, lage, soll in faelle(fenster): - nachrichten = ha.roh_sammeln( - "render_template", - 5.0, - template=bedingung(fenster, gestellt(**lage)), - strict=True, - report_errors=True, - ) - art, wert = auswerten(nachrichten) - ist = _wahrheit(wert) if art == "ERGEBNIS" else None - zeile = ( - f"Bedingung {name}: soll {'ausloesen' if soll else 'schweigen'}, " - f"Home Assistant: {art} {wert!r}" - ) - if ist is soll: - print(zeile) - else: - befunde += 1 - print("BEFUND " + zeile) - return befunde - - -def eingaben_befunde(werte: dict, zeichen_ha: object, jetzt_hier: float) -> list[str]: - """Plausibilitaet der drei live gerechneten Eingaben ohne this (Nachkritik 2026-09-24). - - werte: heute, jetzt und gesendet, wie render_template sie liefert. zeichen_ha: das - Attribut timestamp des Lebenszeichens aus get_states. jetzt_hier: time.time() dieses - Rechners; er steht in derselben Zeitzone wie Home Assistant (Berlin). - """ - befunde = [] - try: - jetzt = float(werte["jetzt"]) - except (TypeError, ValueError, KeyError): - return [f"jetzt ist keine Zahl: {werte.get('jetzt')!r}"] - if abs(jetzt - jetzt_hier) > UHR_TOLERANZ_S: - befunde.append(f"jetzt weicht {jetzt - jetzt_hier:+.0f} s von der Uhr dieses Rechners ab") - heute_hier = datetime.fromtimestamp(jetzt).strftime("%Y-%m-%d") - if str(werte.get("heute")) != heute_hier: - befunde.append(f"heute ist {werte.get('heute')!r}, nach jetzt in Ortszeit {heute_hier}") - try: - gesendet = float(werte["gesendet"]) - except (TypeError, ValueError, KeyError): - return befunde + [f"gesendet ist keine Zahl: {werte.get('gesendet')!r}"] - if gesendet <= 0.0: - befunde.append("gesendet ist 0: das Attribut timestamp des Lebenszeichens fehlt oder ist leer") - elif not isinstance(zeichen_ha, (int, float)) or abs(gesendet - float(zeichen_ha)) > 1.0: - befunde.append(f"gesendet ist nicht der Zeitstempel des Lebenszeichens ({zeichen_ha!r})") - elif gesendet > jetzt + UHR_TOLERANZ_S: - befunde.append("gesendet liegt in der Zukunft") - return befunde - - -def eingaben_pruefen(ha, zeichen_ha: object) -> int: - """Rechnet heute, jetzt und gesendet live per render_template, wie die Automation sie liest. - - zuletzt liest this und ist nur im Lauf der Automation definiert; das belegt spur_pruefen. - Zurueck kommt die Zahl der Befunde. - """ - from vorlage_auswerten import auswerten - - werte: dict[str, object] = {} - for name in ("heute", "jetzt", "gesendet"): - nachrichten = ha.roh_sammeln( - "render_template", - 5.0, - template="{{ " + EINGABEN_LIVE[name] + " }}", - strict=True, - report_errors=True, - ) - art, wert = auswerten(nachrichten) - if art != "ERGEBNIS": - print(f"BEFUND Eingabe {name}: Home Assistant: {art} {wert!r}") - return 1 - werte[name] = wert - befunde = eingaben_befunde(werte, zeichen_ha, time.time()) - for befund in befunde: - print(f"BEFUND Eingabe {befund}") - if not befunde: - abstand = float(werte["jetzt"]) - float(werte["gesendet"]) - print(f"Eingaben live: heute {werte['heute']}, jetzt und gesendet plausibel (Abstand {abstand:.0f} s)") - return len(befunde) - - -def spur_befund(liste: object, lauf: object) -> tuple[str, bool]: - """Die juengste Spur der Automation: (Zeile, ob Befund). - - liste ist die Antwort von trace/list, lauf die von trace/get fuer deren juengsten Lauf. - Belegt, was render_template nicht kann: dass die Bedingung im Lauf der Automation mit - this rechnet (Home Assistant 2026.9.2: automation/__init__.py:707-711 setzt this, - helpers/trace.py:113-118 schreibt error oder result in den Schritt). - """ - if not isinstance(liste, list) or not liste: - return "Spur: noch kein Lauf (die Automation prueft alle fuenf Minuten)", False - if not isinstance(lauf, dict) or not isinstance(lauf.get("trace"), dict): - return f"Spur: Form unbekannt ({type(lauf).__name__})", True - schritte = lauf["trace"].get("condition/0") - if not isinstance(schritte, list) or not schritte or not isinstance(schritte[0], dict): - return "Spur: kein Schritt condition/0 im juengsten Lauf", True - schritt = schritte[0] - if "error" in schritt: - return f"Spur: Bedingung mit Fehler: {str(schritt['error'])[:200]}", True - ergebnis = schritt.get("result") - wert = ergebnis.get("result") if isinstance(ergebnis, dict) else None - if not isinstance(wert, bool): - return f"Spur: Bedingung ohne Wahrheitswert ({str(ergebnis)[:200]})", True - stempel = lauf.get("timestamp") - start = stempel.get("start", "?") if isinstance(stempel, dict) else "?" - return f"Spur: juengster Lauf {start}, Bedingung {wert}, kein Fehler", False - - -def spur_pruefen(ha) -> int: - """Liest die juengste Spur der Automation (trace/list, trace/get). 1 bei Befund.""" - liste = ha.befehl_ok("trace/list", domain="automation", item_id=AUTOMATION_ID) - lauf = None - laeufe = [eintrag for eintrag in liste or [] if isinstance(eintrag, dict)] - if laeufe: - juengster = max( - laeufe, key=lambda eintrag: str((eintrag.get("timestamp") or {}).get("start", "")) - ) - lauf = ha.befehl_ok( - "trace/get", domain="automation", item_id=AUTOMATION_ID, run_id=juengster.get("run_id") - ) - zeile, befund = spur_befund(laeufe, lauf) - print(("BEFUND " if befund else "") + zeile) - return 1 if befund else 0 - - -def pruefen(ha, datei) -> int: - dienste = _dienste(ha) - rest = dienste.get("rest_command", {}) - print(f"rest_command.{REST_DIENST}: {'vorhanden' if REST_DIENST in rest else 'fehlt'}") - print(f"rest_command.reload: {'vorhanden' if 'reload' in rest else 'fehlt'}") - app = sorted(name for name in dienste.get("notify", {}) if name.startswith("mobile_app_")) - print("notify-Dienste der App: " + (", ".join(f"notify.{n}" for n in app) or "keiner")) - for name in ERLAUBT: - inhalt = datei.lies(name) - text = "" if inhalt is None else inhalt.decode("utf-8") - zeile = f"{name}: {'fehlt' if inhalt is None else 'vorhanden'}, Block des Totmanns " - zeile += "da" if MARKE_ANFANG in text else "nicht da" - if name == "configuration.yaml": - fremd = FREMDES_REST_COMMAND.search(ohne_block(text)) - zeile += f", fremdes rest_command {'ja' if fremd else 'nein'}" - print(zeile) - zustaende = ha.befehl_ok("get_states") or [] - auto = [s for s in zustaende if s.get("attributes", {}).get("id") == AUTOMATION_ID] - if auto: - print( - f"Automation: {auto[0]['entity_id']} = {auto[0]['state']}, " - f"{auto[0]['attributes'].get('friendly_name')}" - ) - else: - print("Automation: fehlt") - zeichen = [s for s in zustaende if s.get("entity_id") == LEBENSZEICHEN] - stempel = zeichen[0].get("attributes", {}).get("timestamp") if zeichen else None - if isinstance(stempel, (int, float)): - print(f"Lebenszeichen: vor {time.time() - stempel:.0f} s") - else: - print("Lebenszeichen: nicht lesbar") - live = eingaben_pruefen(ha, stempel) - if auto: - live += spur_pruefen(ha) - befunde = bedingung_pruefen(ha) - print(f"Rechenprobe der Bedingung: {len(faelle(fenster_je_tag(PROBE_KALENDER)))} Lagen, {befunde} Befund(e)") - return 2 if befunde or live else 0 - - -def einrichten(ha, datei, trading: Path, app_dienst: str, zugang: dict) -> int: - dienste = _dienste(ha) - if app_dienst.split(".", 1)[1] not in dienste.get("notify", {}): - print(f"ABBRUCH: {app_dienst} steht nicht in der Dienstliste von Home Assistant") - return 3 - thema, zugriff = zugang["thema"], zugang["token"] - paper = tomllib.loads((trading / "config" / "paper.toml").read_text(encoding="utf-8")) - ntfy_url = str(paper["benachrichtigung"]["ntfy_url"]).rstrip("/") - fenster = fenster_je_tag(zugang["kalender"]) - if not fenster: - print("ABBRUCH: Der Kalender von Alpaca nennt keinen Handelstag") - return 3 - print(f"Kalender: {len(fenster)} Handelstage, {min(fenster)} bis {max(fenster)}") - - alt = {name: datei.lies(name) for name in ERLAUBT} - if alt["configuration.yaml"] is None: - print("ABBRUCH: configuration.yaml nicht lesbar") - return 3 - konf_alt = alt["configuration.yaml"].decode("utf-8") - geh_alt = (alt["secrets.yaml"] or b"").decode("utf-8") - if FREMDES_REST_COMMAND.search(ohne_block(konf_alt)): - print("ABBRUCH: configuration.yaml hat schon einen Abschnitt rest_command; von Hand zusammenfuehren") - return 3 - neu = { - "secrets.yaml": anhaengen( - ohne_block(geh_alt), secrets_block(f"{ntfy_url}/{thema}", zugriff) - ), - "configuration.yaml": anhaengen(ohne_block(konf_alt), config_block(bool(zugriff))), - } - vorher = {"secrets.yaml": geh_alt, "configuration.yaml": konf_alt} - geschrieben = [] - for name in ERLAUBT: - if neu[name] != vorher[name]: - datei.schreibe_und_pruefe(name, neu[name].encode("utf-8")) - geschrieben.append(name) - status, antwort = ha.http_post("/api/config/core/check_config", {}) - ergebnis = antwort.get("result") if isinstance(antwort, dict) else None - if status != 200 or ergebnis != "valid": - fehler = antwort.get("errors") if isinstance(antwort, dict) else antwort - print("BEFUND Konfiguration ungueltig: " + _ohne(str(fehler), thema, zugriff)[:300]) - for name in geschrieben: - datei.schreibe_und_pruefe(name, alt[name] or b"") - print(f"Zurueckgeschrieben: {geschrieben}") - return 2 - print(f"Geschrieben: {geschrieben or 'nichts, die Bloecke standen schon da'}; Konfiguration gueltig") - - rest = _dienste(ha).get("rest_command", {}) - if REST_DIENST not in rest and "reload" in rest: - ha.befehl_ok("call_service", domain="rest_command", service="reload", service_data={}) - rest = _dienste(ha).get("rest_command", {}) - if REST_DIENST not in rest: - print( - f"rest_command.{REST_DIENST} ist noch nicht geladen: Home Assistant neu starten " - "(Einstellungen, System, Neu starten), danach einrichten noch einmal -- die Bloecke " - "stehen dann schon da." - ) - return 4 - - status, antwort = ha.http_post( - f"/api/config/automation/config/{AUTOMATION_ID}", automation(fenster, app_dienst) - ) - print(f"Automation angelegt oder ersetzt: HTTP {status}") - if status != 200: - print("ABBRUCH: Die Automation wurde nicht angenommen: " + _ohne(str(antwort), thema, zugriff)[:200]) - return 2 - return 0 - - -def probe(ha, app_dienst: str) -> int: - dienste = _dienste(ha) - if REST_DIENST not in dienste.get("rest_command", {}): - print(f"ABBRUCH: rest_command.{REST_DIENST} fehlt; erst einrichten") - return 3 - name = app_dienst.split(".", 1)[1] - if name not in dienste.get("notify", {}): - print(f"ABBRUCH: {app_dienst} steht nicht in der Dienstliste von Home Assistant") - return 3 - ha.befehl_ok( - "call_service", domain="rest_command", service=REST_DIENST, service_data={"nachricht": PROBE_TEXT} - ) - ha.befehl_ok( - "call_service", - domain="notify", - service=name, - service_data={"title": "Trading Totmann (Probe)", "message": PROBE_TEXT}, - ) - print("Zwei Probemeldungen abgeschickt (ntfy und HA-App). Ob sie ankamen, sieht nur Pascal.") - return 0 - - -def entfernen(ha, datei) -> int: - status = ha.http_delete(f"/api/config/automation/config/{AUTOMATION_ID}") - print(f"Automation geloescht: HTTP {status}") - for name in ERLAUBT: - inhalt = datei.lies(name) - if inhalt is None: - print(f"{name}: fehlt") - continue - text = inhalt.decode("utf-8") - if MARKE_ANFANG not in text: - print(f"{name}: kein Block") - continue - datei.schreibe_und_pruefe(name, ohne_block(text).encode("utf-8")) - print(f"{name}: Block entfernt") - status, antwort = ha.http_post("/api/config/core/check_config", {}) - print("Konfiguration: " + str(antwort.get("result") if isinstance(antwort, dict) else f"HTTP {status}")) - rest = _dienste(ha).get("rest_command", {}) - if "reload" in rest: - ha.befehl_ok("call_service", domain="rest_command", service="reload", service_data={}) - print("rest_command neu geladen") - else: - print( - f"rest_command.{REST_DIENST} bleibt bis zum naechsten Neustart von Home Assistant " - "geladen; ohne Automation ruft ihn niemand." - ) - return 0 - - -# Die Befehle, die pruefen an Home Assistant schickt. Alle lesen nur. -LESEND = frozenset({"get_services", "get_states", "trace/list", "trace/get", "render_template"}) - - -class _NurLesend: - """Die Verbindung fuer pruefen: laesst nur LESEND und das Lesen von Dateien durch. - - Seit der Pruefung Z2 (2026-09-25). Vorher lief pruefen mit nur_lesen=False, und ein - Fehler in pruefen, der einen schreibenden Befehl schickte, waere nicht abgefangen - worden. Ueber supervisor/api geht nur GET und das Anlegen der Ingress-Sitzung - (ENDPUNKT_SITZUNG), das ConfigDatei auch lesend braucht. Jede andere Methode der - Verbindung -- http_post, http_delete -- ist hier gesperrt. - """ - - def __init__(self, ha, sitzung_endpunkt: str) -> None: - self._ha = ha - self._sitzung = sitzung_endpunkt - - def _pruefe(self, typ: str, felder: dict) -> None: - if typ in LESEND: - return - if typ == "supervisor/api": - methode = str(felder.get("method", "")).lower() - if methode == "get": - return - if methode == "post" and felder.get("endpoint") == self._sitzung: - return - raise RuntimeError(f"pruefen ist lesend: {typ} {felder.get('method', '')} gesperrt".strip()) - - def befehl_ok(self, typ: str, **felder): - self._pruefe(typ, felder) - return self._ha.befehl_ok(typ, **felder) - - def roh_sammeln(self, typ: str, dauer: float, **felder): - self._pruefe(typ, felder) - return self._ha.roh_sammeln(typ, dauer, **felder) - - def __getattr__(self, name: str): - raise RuntimeError(f"pruefen ist lesend: {name} gesperrt") - - -def online(argumente) -> int: - ha_projekt, trading = Path(argumente.ha_projekt), Path(argumente.trading) - zugang = None - if argumente.aktion == "einrichten": - # Vor der Verbindung zu Home Assistant: Der WSL-Teil braucht bis zu WSL_TIMEOUT_S, - # und so lange soll keine Verbindung offen warten. - zugang = aus_wsl(argumente.tage) - print( - f"Aus WSL: Thema Laenge {len(zugang['thema'])}, Token Laenge " - f"{len(zugang['token'])}, {len(zugang['kalender'])} Handelstage" - ) - sys.path.insert(0, str(ha_projekt / "src")) - import config_datei - import ha_verbindung as hv - - # Die Pfadregel des HA-Projekts erlaubt nur themes/. Dieses Werkzeug braucht genau - # secrets.yaml und configuration.yaml und ersetzt die Regel dafuer zur Laufzeit; die - # Methoden von ConfigDatei schlagen pruefe_pfad erst beim Aufruf nach. - config_datei.pruefe_pfad = _nur_zwei_dateien - token = hv.lies_token() - print(f"Token gelesen (Laenge {len(token)}), Instanz aus dem HA-Projekt, Aktion {argumente.aktion}") - # nur_lesen=False auch fuer pruefen: get_services, get_states, trace/list und trace/get - # stehen nicht in der Leseliste des HA-Projekts (src/ha_verbindung.py, LESELISTE; dort - # nur render_template). Fuer pruefen sperrt statt dessen _NurLesend alles ausser genau - # diesen Befehlen (Pruefung Z2); die Dateien oeffnet es ueber ConfigDatei mit - # nur_lesen=True. - with hv.HaVerbindung(hv.BASIS_URL, token, nur_lesen=False) as verbindung: - ha = ( - _NurLesend(verbindung, config_datei.ENDPUNKT_SITZUNG) - if argumente.aktion == "pruefen" - else verbindung - ) - datei = config_datei.ConfigDatei( - hv.BASIS_URL, - lambda endpunkt, methode: ha.befehl_ok("supervisor/api", endpoint=endpunkt, method=methode), - nur_lesen=argumente.aktion == "pruefen", - ) - with datei: - if argumente.aktion == "pruefen": - return pruefen(ha, datei) - if argumente.aktion == "einrichten": - return einrichten(ha, datei, trading, argumente.app_dienst, zugang) - if argumente.aktion == "probe": - return probe(ha, argumente.app_dienst) - return entfernen(ha, datei) - - -def main(argv=None) -> int: - zerleger = argparse.ArgumentParser( - description="Totmann der Trading Plattform in Home Assistant (Paket Z2).", - allow_abbrev=False, - ) - zerleger.add_argument("aktion", choices=["pruefen", "trocken", "einrichten", "probe", "entfernen"]) - zerleger.add_argument("ha_projekt", nargs="?") - zerleger.add_argument("trading", nargs="?") - zerleger.add_argument("--app-dienst", default=None) - zerleger.add_argument("--tage", type=int, default=400) - argumente = zerleger.parse_args(argv) - if argumente.aktion == "trocken": - return trocken() - if not argumente.ha_projekt or not argumente.trading: - zerleger.error("pruefen, einrichten, probe und entfernen brauchen beide Projektordner") - if argumente.aktion in ("einrichten", "probe") and ( - argumente.app_dienst is None or not APP_DIENST_MUSTER.match(argumente.app_dienst) - ): - zerleger.error("--app-dienst notify. fehlt oder hat nicht diese Form") - try: - return online(argumente) - except (Voraussetzung, wsl_teil.Befund) as fehler: - # Diese Texte tragen keinen Wert aus der Geheimnisdatei, nur Feldnamen und Laengen. - print(f"ABBRUCH: {fehler}") - return 3 - except Exception as ausnahme: - # Nur der Typ: Eine Ausnahme kann eine Adresse mit dem Thema oder einen Kopf mit - # einem Schluessel tragen. - print(f"ABBRUCH: {type(ausnahme).__name__}") - return 2 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/ha_totmann_wsl.py b/.vault-tools/trading-plattform-pruefung/ha_totmann_wsl.py deleted file mode 100644 index e35f089..0000000 --- a/.vault-tools/trading-plattform-pruefung/ha_totmann_wsl.py +++ /dev/null @@ -1,136 +0,0 @@ -"""WSL-Teil von ha_totmann.py: Thema, Token und Handelskalender, nur ueber die Pipe (Paket Z2). - -Laeuft in WSL mit dem Python der Trading Plattform (~/venvs/trading; gebraucht wird nur die -Standardbibliothek). Die Geheimnisdatei der Plattform liegt nur hier. Dieses Skript liest -daraus [ntfy] thema und token und [alpaca] key_id und secret_key, fragt den Kalender von -Alpaca (GET /v2/calendar, lesend, ohne Proxy aus der Umgebung) und schreibt genau eine -JSON-Zeile auf stdout: thema, token, kalender. - -Gestartet wird es nur von ha_totmann.py unter Windows, das stdout als Pipe liest und davon -nie etwas ausgibt. Von Hand gestartet stuende das Thema auf der Konsole -- deshalb gibt es -keinen Aufruf dafuer in LIESMICH.md. stderr traegt nur Befunde ohne Werte: Feldnamen und -Laengen. - -ha_totmann.py importiert diese Datei auch unter Windows, fuer die Regeln (zugang, -pruefe_kalender, ausgabe) und fuer seine Aktion trocken; beim Import wird nichts gelesen. - -Aufruf (nur durch ha_totmann.py): python ha_totmann_wsl.py [--geheimnisse PFAD] [--tage 400] -Exit: 0 fertig, 2 Ausnahme, 3 Voraussetzung fehlt. -""" - -import argparse -import json -import re -import sys -import tomllib -import urllib.parse -import urllib.request -from datetime import date -from datetime import timedelta -from pathlib import Path - -GEHEIMNISSE = "/home/claude/.config/trading-plattform/secrets.toml" -ALPACA_KALENDER = "https://paper-api.alpaca.markets/v2/calendar" -ZEITLIMIT_S = 30 -THEMA_MUSTER = re.compile(r"^[A-Za-z0-9_-]{1,64}$") -TOKEN_MUSTER = re.compile(r"^[A-Za-z0-9_-]{1,128}$") -KALENDER_FELDER = ( - ("date", re.compile(r"^\d{4}-\d{2}-\d{2}$")), - ("open", re.compile(r"^\d{2}:\d{2}$")), - ("close", re.compile(r"^\d{2}:\d{2}$")), -) - - -class Befund(Exception): - """Eine Voraussetzung fehlt. Der Text nennt Feldnamen und Laengen, nie einen Wert.""" - - -def zugang(geheim: dict) -> tuple[str, str]: - """Thema und Token aus dem Abschnitt [ntfy]; der Token darf fehlen, das Thema nicht.""" - ntfy = geheim.get("ntfy") or {} - if not isinstance(ntfy, dict): - raise Befund("[ntfy] ist kein Abschnitt") - thema = str(ntfy.get("thema") or "").strip() - token = str(ntfy.get("token") or "").strip() - if not THEMA_MUSTER.match(thema): - raise Befund(f"[ntfy] thema fehlt oder passt nicht zur Regel (Laenge {len(thema)})") - if token and not TOKEN_MUSTER.match(token): - raise Befund(f"[ntfy] token passt nicht zur Regel (Laenge {len(token)})") - return thema, token - - -def pruefe_kalender(daten: object) -> list[dict]: - """Je Handelstag date, open und close; jede andere Form ist ein Befund.""" - if not isinstance(daten, list): - raise Befund("Kalender: Antwort ist keine Liste") - tage = [] - for eintrag in daten: - if not isinstance(eintrag, dict): - raise Befund("Kalender: ein Eintrag ist keine Tabelle") - for feld, muster in KALENDER_FELDER: - wert = eintrag.get(feld) - if not isinstance(wert, str) or not muster.match(wert): - raise Befund(f"Kalender: Feld {feld} nicht lesbar") - tage.append({feld: eintrag[feld] for feld, _muster in KALENDER_FELDER}) - return tage - - -def ausgabe(thema: str, token: str, kalender: list[dict]) -> str: - """Die eine Zeile fuer die Pipe. ASCII, damit kein Zeilentrenner darin stehen kann.""" - return json.dumps( - {"thema": thema, "token": token, "kalender": kalender}, - ensure_ascii=True, - separators=(",", ":"), - ) - - -def kalender_holen(alpaca: dict, tage: int) -> list[dict]: - """GET /v2/calendar von heute bis heute plus tage, lesend, ohne Proxy aus der Umgebung.""" - key_id = str(alpaca.get("key_id") or "") - secret_key = str(alpaca.get("secret_key") or "") - if not key_id or not secret_key: - raise Befund("[alpaca] key_id oder secret_key fehlt") - heute = date.today() - parameter = urllib.parse.urlencode( - {"start": heute.isoformat(), "end": (heute + timedelta(days=tage)).isoformat()} - ) - anfrage = urllib.request.Request( - f"{ALPACA_KALENDER}?{parameter}", - headers={"APCA-API-KEY-ID": key_id, "APCA-API-SECRET-KEY": secret_key}, - ) - oeffner = urllib.request.build_opener(urllib.request.ProxyHandler({})) - with oeffner.open(anfrage, timeout=ZEITLIMIT_S) as antwort: - return pruefe_kalender(json.loads(antwort.read().decode("utf-8"))) - - -def main(argv=None) -> int: - zerleger = argparse.ArgumentParser( - description="WSL-Teil von ha_totmann.py; nur ueber die Pipe benutzen.", - allow_abbrev=False, - ) - zerleger.add_argument("--geheimnisse", default=GEHEIMNISSE) - zerleger.add_argument("--tage", type=int, default=400) - argumente = zerleger.parse_args(argv) - try: - pfad = Path(argumente.geheimnisse) - if not pfad.is_file(): - raise Befund("die Geheimnisdatei liegt nicht am erwarteten Ort") - geheim = tomllib.loads(pfad.read_text(encoding="utf-8")) - thema, token = zugang(geheim) - alpaca = geheim.get("alpaca") or {} - if not isinstance(alpaca, dict): - raise Befund("[alpaca] ist kein Abschnitt") - kalender = kalender_holen(alpaca, argumente.tage) - except Befund as befund: - print(f"BEFUND {befund}", file=sys.stderr) - return 3 - except Exception as ausnahme: - # Nur der Typ: Eine Ausnahme kann eine Adresse oder einen Kopf mit Schluessel tragen. - print(f"ABBRUCH: {type(ausnahme).__name__}", file=sys.stderr) - return 2 - print(ausgabe(thema, token, kalender)) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/handabruf_kerzen.py b/.vault-tools/trading-plattform-pruefung/handabruf_kerzen.py deleted file mode 100644 index 2f5a3a8..0000000 --- a/.vault-tools/trading-plattform-pruefung/handabruf_kerzen.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Handabruf: Wie alt ist die juengste Minutenkerze, die Alpaca per REST fuer AAPL liefert? - -Koordinator-Werkzeug zu Teil 10, nicht fuers Repo. Beantwortet vor dem ersten Paper-Lauf die -ungemessene Frage, ob der IEX-Feed eine gerade abgeschlossene Minute innerhalb von Sekunden -liefert (der Runner wartet `kerze_verzoegerung_s` und versucht es `kerze_versuche` mal) oder -ob die Kerzen verzoegert kommen. Sinnvoll, solange gehandelt wird -- Vorboerse ab 10:00 Uhr -Berliner Zeit (04:00 ET), regulaer ab 15:30 Uhr. Nachts zeigt es nur die letzte Kerze des -Vortags; dann belegt es nur, dass Schluessel und Abruf funktionieren. - -Liest die Geheimnisdatei ueber den Aufloeser des Projekts (TRADING_GEHEIMNISSE, sonst config/secrets.toml), gibt die Schluessel nie aus. - -Aufruf aus dem Projektordner (WSL, Venv): - ~/venvs/trading/bin/python /mnt/d/Claude/.vault-tools/trading-plattform-pruefung/handabruf_kerzen.py [feed] -feed: iex (Standard) oder sip. -""" - -import sys -import tomllib -from datetime import UTC, datetime, timedelta -from pathlib import Path - -import requests - -sys.path.insert(0, str(Path.cwd())) -from trading import geheimnisse # noqa: E402 -- aus dem Projektordner, wie oben verlangt - -DATEN_URL = "https://data.alpaca.markets" -FRISCH_S = 120 # eine abgeschlossene Minute plus die Wartezeit des Runners - - -def main() -> int: - feed = sys.argv[1] if len(sys.argv) > 1 else "iex" - secrets = geheimnisse.pfad() - if not secrets.is_file(): - print(f"VORAUSSETZUNG FEHLT: {geheimnisse.beschreibung()}") - return 3 - werte = tomllib.loads(secrets.read_text(encoding="utf-8"))["alpaca"] - kopf = {"APCA-API-KEY-ID": werte["key_id"], "APCA-API-SECRET-KEY": werte["secret_key"]} - - jetzt = datetime.now(UTC).replace(microsecond=0) - parameter = { - "symbols": "AAPL", - "timeframe": "1Min", - "start": (jetzt - timedelta(hours=36)).strftime("%Y-%m-%dT%H:%M:%SZ"), - "end": jetzt.strftime("%Y-%m-%dT%H:%M:%SZ"), - "feed": feed, - "adjustment": "raw", - "limit": 10_000, - "sort": "desc", - } - gesendet = datetime.now(UTC) - antwort = requests.get(f"{DATEN_URL}/v2/stocks/bars", headers=kopf, params=parameter, timeout=30) - dauer_s = (datetime.now(UTC) - gesendet).total_seconds() - print(f"Feed {feed}, Anfrage bis {jetzt.isoformat()} (jetzt), Antwort {antwort.status_code} in {dauer_s:.2f} s") - if antwort.status_code >= 400: - print("Antworttext:", antwort.text[:300]) - return 2 - kerzen = (antwort.json().get("bars") or {}).get("AAPL") or [] - if not kerzen: - print("keine Kerze in den letzten 36 Stunden -- das ist bei einem laufenden Handelstag ein Befund") - return 2 - juengste = kerzen[0] - zeit = datetime.fromisoformat(juengste["t"].replace("Z", "+00:00")) - alter_s = (jetzt - zeit).total_seconds() - berlin = zeit.astimezone().strftime("%H:%M:%S %Z") - print(f"{len(kerzen)} Kerzen; juengste Kerze beginnt {zeit.isoformat()} ({berlin}), " - f"Alter {alter_s:.0f} s, Schluss {juengste['c']}, Volumen {juengste['v']}") - if alter_s <= FRISCH_S: - print(f"FRISCH: innerhalb von {FRISCH_S} s -- der Runner bekommt seine Kerze rechtzeitig") - else: - print(f"ALT: {alter_s / 60:.1f} min -- entweder wird gerade nicht gehandelt, oder der Feed kommt verzoegert; " - "waehrend der Handelszeit ist das ein Befund") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/handelstag_auswertung.py b/.vault-tools/trading-plattform-pruefung/handelstag_auswertung.py deleted file mode 100644 index 3d5038e..0000000 --- a/.vault-tools/trading-plattform-pruefung/handelstag_auswertung.py +++ /dev/null @@ -1,412 +0,0 @@ -"""Wertet einen Paper-Handelstag aus: Protokoll des Runners, Orders des Paper-Kontos, IEX-Kerzen des Tages. - -Koordinator-Werkzeug zu Teil 10, nicht fuers Repo. Es misst und berichtet; ein Urteil faellt es nur an -vier Stellen, und dort gegen Werte aus dem Protokollkopf (Projektvorgabe, config/paper.toml): - - Annahme bis Fuellung einer Order (filled_at - created_at laut Alpaca) gegen order_timeout_s -- nicht - filled_at - submitted_at: Am 2026-09-21 lagen zwischen Annahme und Weitergabe 8,7 s, und der Runner wartet - ab der Annahme. Beide Spannen werden ausgegeben; mehr als die Haelfte der Grenze ist ein Hinweis, - - Schlussposition ungleich null (seit Paket M auch je Instrument), - - eine Orderzeile des Protokolls, die nicht filled ist, - - eine Position beim Broker mit negativer Stueckzahl (Short): wird nur gemeldet, nicht bewertet. - -Was es misst: - 1. Aus dem Protokoll: Zeilen je Art, Takte je Phase und Killswitch-Zustand, Anteil frischer Kerzen, - erste Entscheidung, Orders, Ereignisse, Ende. Dazu je Takt mit frischer Kerze die Spanne - takt.zeit - (letzte_kerze + 60 s). Das ist eine OBERGRENZE fuer die Zeit, bis die Kerze abrufbar war: - Der Runner wartet vor dem ersten Abruf kerze_verzoegerung_s und schreibt die Taktzeile erst am Ende - des Takts. Den Versuch, bei dem die Kerze kam, schreibt das Protokoll nicht mit; die Spanne laesst ihn - nur schaetzen. Takte mit Order sind getrennt ausgewiesen, weil dort die Orderabfrage in der Spanne steckt. - Das Taktfeld orders ist die Zahl der Orders DIESES Takts (PaperSitzung._takt_ende), keine Summe. - Seit Paket M (2026-09-24) mit mehreren Instrumenten: die Instrumente aus dem Kopf; je Instrument - Entscheidungen, Orders, die aus der Folge erwartete Zahl, erreichte Positionen und Takte mit frischer - Kerze; die groesste Zahl von Orders in einem Takt, die Takte mit Orders in der Schlussphase und die - Positionen je Instrument am Ende. - 2. Mit --broker (lesend, Paper-Konto): Orders des Tages in allen Symbolen des Kopfs mit submitted_at, - filled_at, filled_avg_price; offene Positionen mit Vorzeichen der Stueckzahl. Kein schreibender Aufruf. - 3. Mit --kerzen (lesend): IEX-Minutenkerzen des Tages fuer alle Symbole des Kopfs, ueber alle Seiten; je - gefuellter Order der Abstand des Fuellkurses zum Schluss der letzten vor der Order abgeschlossenen Kerze - IHRES Symbols, in Basispunkten, mit Vorzeichen so, dass positiv Kosten bedeutet (Kauf ueber dem - Schluss, Verkauf darunter). -Was es nicht sieht: Teilausfuehrungen einzeln (nur den Durchschnittskurs der Order), den konsolidierten -Kurs (nur IEX), Gebuehren (das Paper-Konto rechnet keine), und ob ein ausgebliebener Takt am Runner oder -am Rechner lag. Ein Fangnetz fuer die Auswertung, kein Nachweis ueber die Handelsidee. - -Liest die Geheimnisdatei ueber den Aufloeser des Projekts (TRADING_GEHEIMNISSE, sonst config/secrets.toml) und gibt die Schluessel nie aus. -Aufruf aus dem Projektordner (WSL, Venv): - python handelstag_auswertung.py [--broker] [--kerzen] -Exit 0, 3 wenn eine Voraussetzung fehlt (Protokoll, Kopfzeile, Schluessel, Netz). -""" - -import json -import statistics -import sys -import tomllib -from collections import Counter -from datetime import UTC, datetime, timedelta -from pathlib import Path - -sys.path.insert(0, str(Path.cwd())) -from trading import geheimnisse # noqa: E402 -- aus dem Projektordner, wie oben verlangt - -HANDEL_URL = "https://paper-api.alpaca.markets" -DATEN_URL = "https://data.alpaca.markets" - -# Seiten des Kerzenabrufs, hoechstens. Zehn Symbole mal 390 Minuten sind rund 3.900 Kerzen, eine Seite. -MAX_SEITEN = 20 - - -def zeitpunkt(text: str) -> datetime: - return datetime.fromisoformat(text.replace("Z", "+00:00")) - - -def instrumente_aus(kopf: dict) -> list[str]: - """Die Instrumente des Laufs: instrumente seit Paket M, sonst instrument, sonst AAPL wie bisher.""" - liste = kopf.get("instrumente") - if isinstance(liste, list) and liste: - return [str(kennung) for kennung in liste] - einzeln = kopf.get("instrument") - return [str(einzeln)] if einzeln else ["AAPL.XNAS"] - - -def symbole_aus(kopf: dict) -> list[str]: - return [kennung.split(".")[0] for kennung in instrumente_aus(kopf)] - - -def erwartete_orders(agent: object, index: int, entscheidungen: int) -> int | None: - """Die Orders, die eine Folge bei so vielen Entscheidungen stellt, samt Schlussbein. - - Nur fuer quelle folge, sonst None. Der Folgezaehler des Instruments beginnt bei index mal - versatz_takte und zaehlt je Entscheidung eins weiter -- in einer Minute ohne frische Kerze - entscheidet das Instrument nicht, und der Zaehler steht. Angenommen ist ein flacher Start; - ein Seitenwechsel sind zwei Beine; steht die Folge am Ende nicht auf null, kommt das - Schlussbein dazu. Geteilte Beine (ueber max_notional_je_order) zaehlt die Rechnung nicht mit. - """ - if not isinstance(agent, dict) or agent.get("quelle") != "folge": - return None - stufen = agent.get("stufen") - takte = agent.get("takte_je_stufe") - if not isinstance(stufen, list) or not stufen or not isinstance(takte, int) or takte < 1: - return None - versatz = index * int(agent.get("versatz_takte") or 0) - vorher = 0 - orders = 0 - for zaehler in range(versatz, versatz + entscheidungen): - stufe = stufen[(zaehler // takte) % len(stufen)] - if stufe != vorher: - orders += 2 if stufe * vorher < 0 else 1 - vorher = stufe - if vorher != 0: - orders += 1 - return orders - - -def lies_protokoll(pfad: Path) -> list[dict]: - zeilen = [] - # Dateiiteration statt splitlines(): splitlines trennt auch an U+2028 (Lesson vom 2026-09-17). - with open(pfad, encoding="utf-8") as datei: - for nummer, zeile in enumerate(datei, 1): - if not zeile.strip(): - continue - try: - zeilen.append(json.loads(zeile)) - except ValueError: - print(f" HINWEIS: Zeile {nummer} ist kein JSON (abgebrochener Schreibvorgang?)") - return zeilen - - -def verteilung(werte: list[float], name: str) -> None: - if not werte: - print(f" {name}: keine Werte") - return - sortiert = sorted(werte) - p95 = sortiert[min(len(sortiert) - 1, int(round(0.95 * (len(sortiert) - 1))))] - print( - f" {name}: n={len(werte)}, min {sortiert[0]:.2f} s, median {statistics.median(sortiert):.2f} s, " - f"p95 {p95:.2f} s, max {sortiert[-1]:.2f} s" - ) - - -def teil_protokoll(zeilen: list[dict]) -> dict | None: - arten = Counter(z.get("art") for z in zeilen) - print(f"zeilen: {len(zeilen)} {dict(arten)}") - koepfe = [z for z in zeilen if z.get("art") == "kopf"] - if len(koepfe) != 1: - print(f"VORAUSSETZUNG FEHLT: genau eine Kopfzeile erwartet, gefunden {len(koepfe)}") - return None - kopf = koepfe[0] - print( - f"kopf: modus {kopf.get('modus')}, agent {kopf.get('agent')}, feed {kopf.get('feed')}, " - f"order_timeout_s {kopf.get('order_timeout_s')}, start {kopf.get('zeit')}" - ) - instrumente = instrumente_aus(kopf) - print(f"instrumente: {instrumente}") - takte = [z for z in zeilen if z.get("art") == "takt"] - print(f"takte: {len(takte)}, phasen {dict(Counter(t.get('phase') for t in takte))}") - print(f"killswitch je takt: {dict(Counter(t.get('killswitch') for t in takte))}") - print(f"markt offen je takt: {dict(Counter(t.get('markt_offen') for t in takte))}") - frisch = Counter(t.get("frische_kerze") for t in takte) - print(f"frische kerze je takt: {dict(frisch)}") - ohne = [t for t in takte if t.get("markt_offen") and not t.get("frische_kerze")] - if ohne: - print(f" takte bei offenem markt OHNE frische kerze: {len(ohne)}; erste fuenf: {[t.get('zeit') for t in ohne[:5]]}") - - # orders in der Taktzeile sind die Orders DIESES Takts (self._orders - vorher), keine laufende - # Summe. Bis Paket M las das Werkzeug sie als Summe und ordnete nur Takte mit wachsender Zahl zu. - orderschritte = {t.get("schritt") for t in takte if isinstance(t.get("orders"), int) and t["orders"] > 0} - ruhig, mit_order = [], [] - for t in takte: - if not t.get("frische_kerze") or not t.get("letzte_kerze") or not t.get("zeit"): - continue - spanne = (zeitpunkt(t["zeit"]) - (zeitpunkt(t["letzte_kerze"]) + timedelta(seconds=60))).total_seconds() - (mit_order if t.get("schritt") in orderschritte else ruhig).append(spanne) - if kopf.get("modus") != "paper": - # In der Probe sind die Kerzen gespielt: Die Spanne misst dort den Abstand zum Vortag, nicht den Feed. - print(f"spanne taktzeile gegen minutenende: im modus {kopf.get('modus')!r} nicht aussagekraeftig, ausgelassen") - else: - print("spanne taktzeile gegen minutenende (obergrenze fuer 'kerze abrufbar'):") - verteilung(ruhig, "takte ohne order") - verteilung(mit_order, "takte mit order") - if ruhig: - eimer = Counter(int(s // 1) for s in ruhig) - haeufig = sorted(eimer.items())[:20] - print(f" sekunden-eimer (takte ohne order, die ersten {len(haeufig)} von {len(eimer)}):", dict(haeufig)) - - entscheidungen = [t for t in takte if t.get("aktion") is not None] - if entscheidungen: - erste = entscheidungen[0] - print( - f"erste entscheidung: schritt {erste.get('schritt')}, zeit {erste.get('zeit')}, " - f"kerzen {erste.get('kerzen')}, aktion {erste.get('aktion')}, ziel {erste.get('zielposition')}" - ) - print(f"aktionen: {dict(Counter(t.get('aktion') for t in entscheidungen))}") - print(f"positionen je takt: {dict(Counter(t.get('position') for t in takte))}") - if len(instrumente) > 1: - erreicht = { - kennung: sorted( - { - t["positionen"][kennung] - for t in takte - if isinstance(t.get("positionen"), dict) and kennung in t["positionen"] - } - ) - for kennung in instrumente - } - print(f"positionen erreicht je instrument: {erreicht}") - frisch_je = { - kennung: sum( - 1 for t in takte if isinstance(t.get("frische_kerzen"), dict) and t["frische_kerzen"].get(kennung) - ) - for kennung in instrumente - } - print(f"takte mit frischer kerze je instrument: {frisch_je}") - entscheidungen_je = { - kennung: sum( - 1 for t in takte if isinstance(t.get("aktionen"), dict) and t["aktionen"].get(kennung) is not None - ) - for kennung in instrumente - } - else: - entscheidungen_je = {instrumente[0]: len(entscheidungen)} - orders_je_takt = [t.get("orders") for t in takte if isinstance(t.get("orders"), int)] - print(f"groesste zahl von orders in einem takt: {max(orders_je_takt) if orders_je_takt else 0}") - schluss_mit_order = [t for t in takte if t.get("phase") == "schliesst" and (t.get("orders") or 0) > 0] - print(f"takte mit orders in der schlussphase: {len(schluss_mit_order)}") - hinweise = Counter(t.get("hinweis") for t in takte if t.get("hinweis")) - for text, zahl in hinweise.most_common(8): - print(f" hinweis x{zahl}: {str(text)[:140]}") - ereignisse = [z for z in zeilen if z.get("art") == "ereignis"] - print(f"ereignisse je art: {dict(sorted(Counter(z.get('was') for z in ereignisse).items()))}") - for z in ereignisse: - print(f"ereignis {z.get('zeit')}: {z.get('was')} | {str(z.get('text'))[:160]} | phase {z.get('phase')}") - orderzeilen = [z for z in zeilen if z.get("art") == "order"] - for z in orderzeilen: - print( - f"order {z.get('zeit')}: {z.get('instrument') or ''} {z.get('seite')} {z.get('menge')} " - f"gefuellt {z.get('gefuellt')} status {z.get('status')} position danach {z.get('position_danach')} id {z.get('id')}" - ) - if z.get("status") != "filled": - print(f"BEFUND: Orderzeile nicht gefuellt ({z.get('instrument') or ''} {z.get('seite')} {z.get('menge')}, status {z.get('status')})") - orders_je = Counter(z.get("instrument") for z in orderzeilen if z.get("instrument")) - print(f"orders je instrument: {dict(sorted(orders_je.items()))}") - if len(instrumente) == 1 and not orders_je: - # Protokolle von vor 0.28.0 tragen kein Instrument in der Orderzeile. - orders_je = Counter({instrumente[0]: len(orderzeilen)}) - print("je instrument (erwartet: aus stufen, versatz und entscheidungen, samt schlussbein, ohne geteilte beine):") - for index, kennung in enumerate(instrumente): - erwartet = erwartete_orders(kopf.get("agent"), index, entscheidungen_je.get(kennung, 0)) - text = "" if erwartet is None else f", erwartet {erwartet}" - print(f" {kennung}: entscheidungen {entscheidungen_je.get(kennung, 0)}, orders {orders_je.get(kennung, 0)}{text}") - enden = [z for z in zeilen if z.get("art") == "ende"] - if not enden: - print("BEFUND: keine Zeile 'ende' -- der Runner wurde nicht ordentlich beendet") - for ende in enden: - print( - f"ende {ende.get('zeit')}: grund {ende.get('grund')}, fehler {ende.get('fehler')}, position {ende.get('position')}, " - f"orders {ende.get('orders')}, schritte {ende.get('schritte')}, kontowert {ende.get('kontowert')}" - ) - if ende.get("position") not in (0, None): - print("BEFUND: Schlussposition ungleich null") - positionen = ende.get("positionen") - if isinstance(positionen, dict): - print(f" positionen am ende: {positionen}") - offen = {kennung: menge for kennung, menge in positionen.items() if menge != 0} - if offen: - print(f"BEFUND: Schlussposition ungleich null in {offen}") - return kopf - - -def kopfzeilen() -> dict[str, str] | None: - secrets = geheimnisse.pfad() - if not secrets.is_file(): - print(f"VORAUSSETZUNG FEHLT: {geheimnisse.beschreibung()}") - return None - werte = tomllib.loads(secrets.read_text(encoding="utf-8"))["alpaca"] - return {"APCA-API-KEY-ID": werte["key_id"], "APCA-API-SECRET-KEY": werte["secret_key"]} - - -def teil_broker(kopf: dict, tag_start: datetime, anmeldung: dict[str, str]) -> list[dict] | None: - import requests - - parameter = { - "status": "all", - "symbols": ",".join(symbole_aus(kopf)), - "after": tag_start.strftime("%Y-%m-%dT%H:%M:%SZ"), - "until": (tag_start + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ"), - "direction": "asc", - "limit": 500, - } - antwort = requests.get(f"{HANDEL_URL}/v2/orders", headers=anmeldung, params=parameter, timeout=30) - if antwort.status_code != 200: - print(f"VORAUSSETZUNG FEHLT: /v2/orders antwortet {antwort.status_code}: {antwort.text[:200]}") - return None - orders = antwort.json() - grenze = float(kopf.get("order_timeout_s") or 0) - print(f"orders beim broker am {tag_start.date()}: {len(orders)} (order_timeout_s laut protokoll: {grenze})") - print(f"orders beim broker je symbol: {dict(sorted(Counter(o.get('symbol') for o in orders).items()))}") - for o in orders: - erstellt = zeitpunkt(o["created_at"]) if o.get("created_at") else None - eingereicht = zeitpunkt(o["submitted_at"]) if o.get("submitted_at") else None - gefuellt = zeitpunkt(o["filled_at"]) if o.get("filled_at") else None - dauer = (gefuellt - eingereicht).total_seconds() if eingereicht and gefuellt else None - text = f"{dauer:.3f} s" if dauer is not None else "nicht gefuellt" - print( - f" {o.get('side')} {o.get('qty')} {o.get('symbol')} typ {o.get('type')} status {o.get('status')} " - f"eingereicht {o.get('submitted_at')} fuellzeit {text} kurs {o.get('filled_avg_price')} id {str(o.get('client_order_id'))[:24]}" - ) - # Gemessen am 2026-09-21: Alpaca nahm eine Market-Order an (created_at) und gab sie erst 8,7 s - # spaeter weiter (submitted_at). filled_at - submitted_at allein (3,8 s) verschwieg das; der Runner - # wartet aber ab der Annahme. Gegen order_timeout_s zaehlt deshalb die ganze Spanne ab created_at. - ganz = (gefuellt - erstellt).total_seconds() if erstellt and gefuellt else None - if erstellt and eingereicht: - ganz_text = f"{ganz:.3f} s" if ganz is not None else "nicht gefuellt" - print( - f" angenommen {o.get('created_at')}: bis eingereicht {(eingereicht - erstellt).total_seconds():+.3f} s, " - f"angenommen bis gefuellt {ganz_text}" - ) - massgeblich = ganz if ganz is not None else dauer - if massgeblich is not None and grenze and massgeblich > grenze: - print(f" BEFUND: Annahme bis Fuellung {massgeblich:.1f} s ueber order_timeout_s {grenze}") - elif massgeblich is not None and grenze and massgeblich > grenze / 2: - print(f" HINWEIS: Annahme bis Fuellung {massgeblich:.1f} s ist mehr als die Haelfte von order_timeout_s {grenze}") - positionen = requests.get(f"{HANDEL_URL}/v2/positions", headers=anmeldung, timeout=30) - if positionen.status_code == 200: - liste = positionen.json() - print(f"offene positionen beim broker jetzt: {len(liste)}") - for p in liste: - print(f" {p.get('symbol')} qty {p.get('qty')} side {p.get('side')}") - if str(p.get("qty", "")).startswith("-"): - print(" MESSUNG: Short-Position kommt mit NEGATIVER Stueckzahl") - else: - print(f" positionen: HTTP {positionen.status_code}") - return orders - - -def teil_kerzen(orders: list[dict], tag_start: datetime, feed: str, anmeldung: dict[str, str], symbole: list[str]) -> None: - import requests - - parameter = { - "symbols": ",".join(symbole), - "timeframe": "1Min", - "start": tag_start.strftime("%Y-%m-%dT%H:%M:%SZ"), - "end": (tag_start + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ"), - "feed": feed, - "adjustment": "raw", - "limit": 10_000, - "sort": "asc", - } - kerzen: dict[str, list[dict]] = {symbol: [] for symbol in symbole} - for _seite in range(MAX_SEITEN): - antwort = requests.get(f"{DATEN_URL}/v2/stocks/bars", headers=anmeldung, params=parameter, timeout=30) - if antwort.status_code != 200: - print(f"VORAUSSETZUNG FEHLT: /v2/stocks/bars antwortet {antwort.status_code}: {antwort.text[:200]}") - return - nutzlast = antwort.json() - for symbol, liste in (nutzlast.get("bars") or {}).items(): - kerzen.setdefault(symbol, []).extend(liste or []) - token = nutzlast.get("next_page_token") - if not token: - break - parameter["page_token"] = token - print(f"kerzen ({feed}) am {tag_start.date()}: {dict((symbol, len(liste)) for symbol, liste in kerzen.items())}") - nach_zeit = {symbol: {zeitpunkt(k["t"]): k for k in liste} for symbol, liste in kerzen.items()} - for o in orders: - if not o.get("filled_at") or not o.get("filled_avg_price") or not o.get("submitted_at"): - continue - eingereicht = zeitpunkt(o["submitted_at"]) - # Die letzte vor der Order ABGESCHLOSSENE Kerze beginnt zwei Minutenanfaenge vor der laufenden Minute - # nur dann nicht, wenn die Order in derselben Minute liegt wie ihr Schluss: Minute abrunden, eine zurueck. - entscheidungskerze = eingereicht.replace(second=0, microsecond=0) - timedelta(minutes=1) - kerze = nach_zeit.get(o.get("symbol"), {}).get(entscheidungskerze) - if kerze is None: - print(f" {o.get('symbol')} {o.get('side')} {o.get('qty')}: keine Kerze um {entscheidungskerze.isoformat()} (IEX-Luecke)") - continue - schluss = float(kerze["c"]) - kurs = float(o["filled_avg_price"]) - vorzeichen = 1.0 if o.get("side") == "buy" else -1.0 - bp = vorzeichen * (kurs - schluss) / schluss * 10_000 - print( - f" {o.get('symbol')} {o.get('side')} {o.get('qty')}: fuellkurs {kurs:.4f}, schluss der kerze {entscheidungskerze.strftime('%H:%M')} UTC {schluss:.4f}, " - f"kosten {bp:+.2f} bp ({vorzeichen * (kurs - schluss) * float(o.get('filled_qty') or 0):+.2f} USD)" - ) - - -def main() -> int: - argumente = [a for a in sys.argv[1:] if not a.startswith("--")] - schalter = {a for a in sys.argv[1:] if a.startswith("--")} - unbekannt = schalter - {"--broker", "--kerzen"} - if len(argumente) != 1 or unbekannt: - print(__doc__) - return 3 - pfad = Path(argumente[0]) - if not pfad.is_file(): - print(f"VORAUSSETZUNG FEHLT: {pfad}") - return 3 - print(f"== protokoll {pfad.name}") - kopf = teil_protokoll(lies_protokoll(pfad)) - if kopf is None: - return 3 - if not schalter: - return 0 - anmeldung = kopfzeilen() - if anmeldung is None: - return 3 - tag_start = zeitpunkt(kopf["zeit"]).astimezone(UTC).replace(hour=0, minute=0, second=0, microsecond=0) - orders: list[dict] = [] - if "--broker" in schalter or "--kerzen" in schalter: - print("== broker (lesend)") - ergebnis = teil_broker(kopf, tag_start, anmeldung) - if ergebnis is None: - return 3 - orders = ergebnis - if "--kerzen" in schalter: - print("== ausfuehrungskosten gegen die IEX-kerze") - teil_kerzen(orders, tag_start, str(kopf.get("feed") or "iex"), anmeldung, symbole_aus(kopf)) - return 0 - - -if __name__ == "__main__": - try: - sys.exit(main()) - except Exception as ausnahme: # noqa: BLE001 - ein Absturz des Werkzeugs ist selbst ein Befund - print(f"BEFUND: das Werkzeug ist abgebrochen: {ausnahme!r}") - sys.exit(2) diff --git a/.vault-tools/trading-plattform-pruefung/hausmelder_echtlauf.py b/.vault-tools/trading-plattform-pruefung/hausmelder_echtlauf.py deleted file mode 100644 index 9e10f1e..0000000 --- a/.vault-tools/trading-plattform-pruefung/hausmelder_echtlauf.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Eigener Lauf des Hausmelders gegen einen mitschreibenden Server auf 127.0.0.1 -- und wahlweise ein POST ans echte HA. - -Koordinator-Pruefcode, nicht Teil des Repos. Teil 1 (immer): echter Hausmelder-Faden, echter requests-POST, -lokaler HTTP-Server, gestellte Statusdatei, Wegwerf-Register. Teil 2 (nur mit --echt ): ein -einziger pruefe()-Durchlauf mit unterscheidbaren Werten an die Adresse aus der Geheimnisdatei; die Adresse -wird nie ausgegeben. Rueckgelesen wird danach unter Windows mit ha_einrichten.py. - -Aufruf (WSL): python hausmelder_echtlauf.py [--echt ] -""" - -import json -import os -import sys -import threading -import time -from datetime import UTC -from datetime import datetime -from http.server import BaseHTTPRequestHandler -from http.server import ThreadingHTTPServer -from pathlib import Path - -CODE = Path(sys.argv[1]).resolve() -WEG = Path(sys.argv[2]).resolve() -ECHT_SECRETS = Path(sys.argv[4]).resolve() if len(sys.argv) > 4 and sys.argv[3] == "--echt" else None - -os.chdir(CODE) -sys.path.insert(0, str(CODE)) - -from trading.dashboard import hausmelder as modul # noqa: E402 -from trading.dashboard.hausmelder import Hausmelder # noqa: E402 -from trading.dashboard.hausmelder import killswitch_leser # noqa: E402 -from trading.dashboard.hausmelder import lies_hausmelder_config # noqa: E402 -from trading.dashboard.hausmelder import starte_hausmelder # noqa: E402 -from trading.paper.status import schreibe_status # noqa: E402 -from trading.registry.registry import Registry # noqa: E402 - -EMPFANGEN = [] -ANTWORT = {"code": 200, "location": None} - - -class Mitschreiber(BaseHTTPRequestHandler): - def do_POST(self): - laenge = int(self.headers.get("Content-Length", "0")) - rumpf = self.rfile.read(laenge) - EMPFANGEN.append({"pfad": self.path, "typ": self.headers.get("Content-Type"), - "daten": json.loads(rumpf.decode("utf-8")), "antwort": ANTWORT["code"]}) - self.send_response(ANTWORT["code"]) - if ANTWORT["location"]: - self.send_header("Location", ANTWORT["location"]) - self.send_header("Content-Length", "0") - self.end_headers() - - def do_GET(self): - EMPFANGEN.append({"pfad": self.path, "typ": "GET", "daten": None, "antwort": 200}) - self.send_response(200) - self.send_header("Content-Length", "0") - self.end_headers() - - def log_message(self, *a): - pass - - -def status(pfad, **felder): - grund = {"zeit": datetime.now(UTC).isoformat(), "modus": "paper", "phase": "handelt", "position": 0, - "kontowert": 100000.0, "startkapital": 100000.0} - grund.update(felder) - schreibe_status(pfad, grund) - - -def warte_bis(bedingung, sekunden=5.0): - ende = time.monotonic() + sekunden - while time.monotonic() < ende: - if bedingung(): - return True - time.sleep(0.05) - return False - - -def main(): - WEG.mkdir(parents=True, exist_ok=False) - befunde = [] - server = ThreadingHTTPServer(("127.0.0.1", 0), Mitschreiber) - port = server.server_address[1] - threading.Thread(target=server.serve_forever, daemon=True).start() - kennung = "nachtlauf-" + "a" * 40 - url = f"http://127.0.0.1:{port}/api/webhook/{kennung}" - - registry = Registry(db_pfad=WEG / "registry.db", artefakt_wurzel=WEG / "artifacts") - registry.setze_killswitch_zustand("angehalten", ausloeser="neustart") - statusdatei = WEG / "paper" / "status.json" - statusdatei.parent.mkdir(parents=True) - - # Teil 1a: der Faden selbst, mit kurzem Takt. - melder = Hausmelder(webhook_url=url, status_datei=statusdatei, killswitch=killswitch_leser(registry), - takt_s=0.1, lebenszeichen_s=1.5) - melder.starte() - if not warte_bis(lambda: len(EMPFANGEN) == 1): - befunde.append("kein erster POST ohne Statusdatei") - erster = EMPFANGEN[0]["daten"] if EMPFANGEN else {} - print("1. ohne Statusdatei:", json.dumps(erster, ensure_ascii=False)) - if erster.get("phase") != "kein_status" or erster.get("killswitch") != "angehalten" or erster.get("herzschlag_alter_s") != -1: - befunde.append("Rueckfall ohne Statusdatei stimmt nicht") - if EMPFANGEN and (EMPFANGEN[0]["pfad"] != f"/api/webhook/{kennung}" or EMPFANGEN[0]["typ"] != "application/json"): - befunde.append("Pfad oder Content-Type falsch") - - status(statusdatei, phase="handelt", position=20, kontowert=100012.345) - warte_bis(lambda: len(EMPFANGEN) == 2) - zweiter = EMPFANGEN[-1]["daten"] - print("2. nach Statusaenderung:", json.dumps(zweiter, ensure_ascii=False)) - if zweiter.get("position") != 20 or zweiter.get("tagesergebnis_usd") != 12.35 or zweiter.get("phase") != "handelt": - befunde.append("Statuswerte kommen nicht an") - - # Ohne Aenderung: In 0,8 s (acht Takte) darf nichts kommen; der Herzschlag wird dabei aelter. - stand = len(EMPFANGEN) - time.sleep(0.8) - if len(EMPFANGEN) != stand: - befunde.append(f"ohne Aenderung kamen {len(EMPFANGEN) - stand} POST(s)") - # Lebenszeichen nach 1,5 s. - if not warte_bis(lambda: len(EMPFANGEN) == stand + 1, 3.0): - befunde.append("kein Lebenszeichen") - print(f"3. ohne Aenderung still, Lebenszeichen nach der Frist: {len(EMPFANGEN)} POSTs bisher") - - # Killswitch aus dem Register, ohne dass die Statusdatei sich aendert. - registry.setze_killswitch_zustand("gesperrt", ausloeser="pascal") - stand = len(EMPFANGEN) - warte_bis(lambda: len(EMPFANGEN) == stand + 1) - print("4. Killswitch:", EMPFANGEN[-1]["daten"].get("killswitch")) - if EMPFANGEN[-1]["daten"].get("killswitch") != "gesperrt": - befunde.append("Killswitch-Wechsel kommt nicht an") - - # Fehlschlag: 500 kippt den Merker nicht, es wird wiederholt; danach 200. - ANTWORT["code"] = 500 - status(statusdatei, phase="pausiert", position=0, kontowert=99990.0) - stand = len(EMPFANGEN) - warte_bis(lambda: len(EMPFANGEN) >= stand + 3) - wiederholt = len(EMPFANGEN) - stand - ANTWORT["code"] = 200 - warte_bis(lambda: EMPFANGEN[-1]["antwort"] == 200) - stand = len(EMPFANGEN) - time.sleep(0.6) - print(f"5. bei HTTP 500 wiederholt: {wiederholt} Versuche; nach 200 still: {len(EMPFANGEN) - stand} weitere; " - f"Fehlschlaege: {sorted(set(melder.fehlschlaege))}") - if wiederholt < 3 or len(EMPFANGEN) != stand: - befunde.append("Wiederholung nach Fehlschlag stimmt nicht") - if EMPFANGEN[-1]["daten"].get("tagesergebnis_usd") != -10.0: - befunde.append("negatives Tagesergebnis stimmt nicht") - - # Weiterleitung: wird nicht gefolgt (kein GET am Server), gilt als Fehlschlag. - ANTWORT["code"] = 302 - ANTWORT["location"] = f"http://127.0.0.1:{port}/woanders" - status(statusdatei, phase="handelt", position=10) - stand = len(EMPFANGEN) - warte_bis(lambda: len(EMPFANGEN) >= stand + 2) - gets = [e for e in EMPFANGEN if e["typ"] == "GET" or e["pfad"] == "/woanders"] - ANTWORT["code"] = 200 - ANTWORT["location"] = None - warte_bis(lambda: EMPFANGEN[-1]["antwort"] == 200) - print(f"6. Weiterleitung: gefolgt {len(gets)}-mal; Fehlschlaege: {sorted(set(melder.fehlschlaege))}") - if gets: - befunde.append("einer Weiterleitung wurde gefolgt") - - # Veraltet: Herzschlag 200 s alt, Phase nicht beendet. - status(statusdatei, phase="handelt", position=10, - zeit=datetime.fromtimestamp(time.time() - 200, UTC).isoformat()) - stand = len(EMPFANGEN) - warte_bis(lambda: len(EMPFANGEN) >= stand + 1) - print("7. veraltet:", EMPFANGEN[-1]["daten"].get("veraltet"), EMPFANGEN[-1]["daten"].get("herzschlag_alter_s")) - if EMPFANGEN[-1]["daten"].get("veraltet") is not True: - befunde.append("veraltet wird nicht gemeldet") - - melder.stoppe() - if melder.laeuft: - befunde.append("Faden laeuft nach stoppe() weiter") - geheim = [text for text in melder.fehlschlaege if kennung in text or "127.0.0.1" in text] - if geheim or kennung in repr(melder): - befunde.append("Adresse in Fehlschlag oder repr") - schluessel = sorted(EMPFANGEN[-1]["daten"]) - print("8. Schluessel der Nutzlast:", schluessel) - if schluessel != sorted(["phase", "modus", "killswitch", "position", "tagesergebnis_usd", - "herzschlag_alter_s", "veraltet", "gesendet"]): - befunde.append("Nutzlast traegt andere Schluessel") - - # Teil 1b: Verdrahtung ueber die Geheimnisdatei. - secrets = WEG / "secrets.toml" - secrets.write_text(f'[homeassistant]\nwebhook_url = "{url}"\naktiv = true\n', encoding="utf-8") - stand = len(EMPFANGEN) - zweiter_melder = starte_hausmelder(status_datei=statusdatei, killswitch=killswitch_leser(registry), - secrets_pfad=secrets) - ok = zweiter_melder is not None and warte_bis(lambda: len(EMPFANGEN) > stand) - if zweiter_melder is not None: - zweiter_melder.stoppe() - print(f"9. starte_hausmelder mit aktiv = true: Faden {'lief und hat gesendet' if ok else 'FEHLT'}") - if not ok: - befunde.append("starte_hausmelder sendet nicht") - secrets.write_text(f'[homeassistant]\nwebhook_url = "{url}"\naktiv = false\n', encoding="utf-8") - if starte_hausmelder(status_datei=statusdatei, killswitch=killswitch_leser(registry), secrets_pfad=secrets) is not None: - befunde.append("aktiv = false startet einen Faden") - secrets.write_text('[homeassistant]\nwebhook_url = "ftp://falsch"\naktiv = true\n', encoding="utf-8") - if starte_hausmelder(status_datei=statusdatei, killswitch=killswitch_leser(registry), secrets_pfad=secrets) is not None: - befunde.append("eine falsche Adresse startet einen Faden") - server.shutdown() - - # Teil 2: ein POST ans echte HA, mit Werten, die sich von der neutralen Lage unterscheiden. - if ECHT_SECRETS is not None: - config = lies_hausmelder_config(ECHT_SECRETS) - if config is None or not config.webhook_url: - befunde.append("echte Geheimnisdatei ohne [homeassistant]") - else: - status(statusdatei, modus="generalprobe", phase="handelt", position=7, kontowert=100004.56) - registry.setze_killswitch_zustand("aktiv", ausloeser="pascal") - echter = Hausmelder(webhook_url=config.webhook_url, status_datei=statusdatei, - killswitch=killswitch_leser(registry)) - echter.pruefe() - print(f"10. echtes HA: zugestellt={echter._zuletzt is not None}, Fehlschlaege={echter.fehlschlaege}, " - f"Laenge der Adresse {len(config.webhook_url)}") - if echter._zuletzt is None: - befunde.append("echtes HA hat nicht mit 200 geantwortet") - - print(f"{len(befunde)} Befund(e): {befunde}") - return 2 if befunde else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/hole_kerzen.py b/.vault-tools/trading-plattform-pruefung/hole_kerzen.py deleted file mode 100644 index 233bcaa..0000000 --- a/.vault-tools/trading-plattform-pruefung/hole_kerzen.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Holt die echten IEX-Minutenkerzen eines Handelstags und legt sie als JSON ab. Nur lesend. - -Koordinator-Werkzeug, nicht Teil des Repos. Der einzige Netzzugriff ist der lesende Kerzenabruf -bei Alpaca; ausgegeben werden Anzahl und Eckkurse, nie ein Schluessel. Die Datei ist die Eingabe -fuer neustart_echtlauf.py. Entstanden in der Abschlusspruefung zu Paket F (2026-09-20). -Aufruf (WSL, aus dem Projektordner): python hole_kerzen.py JJJJ-MM-TT -""" - -import json -import sys -from datetime import datetime -from pathlib import Path - -sys.path.insert(0, str(Path.cwd())) - -from trading.paper.config import lade_paper_config -from trading.paper.kerzen import HANDELSZONE -from trading.paper.kerzen import AlpacaKerzenquelle -from trading.paper.kerzen import handelsende - - -def main() -> int: - datum, ziel = sys.argv[1], Path(sys.argv[2]) - config = lade_paper_config("config/paper.toml") - mitternacht = datetime.strptime(datum, "%Y-%m-%d").replace(tzinfo=HANDELSZONE) - quelle = AlpacaKerzenquelle.from_secrets(None, feed=config.feed, timeout=20.0) - kerzen = quelle.kerzen_des_tages(config.instrument_id, handelsende(mitternacht)) - ziel.write_text( - json.dumps( - [ - {"zeit": k.zeit.isoformat(), "open": k.open, "high": k.high, - "low": k.low, "close": k.close, "volume": k.volume} - for k in kerzen - ] - ), - encoding="utf-8", - ) - print(f"{datum}: {len(kerzen)} Kerzen in {config.instrument_id} nach {ziel}") - if kerzen: - kurse = [k.close for k in kerzen] - print(f" erste {kerzen[0].zeit.isoformat()} Schluss {kurse[0]}, " - f"letzte {kerzen[-1].zeit.isoformat()} Schluss {kurse[-1]}, " - f"Tief {min(kurse)}, Hoch {max(kurse)}") - return 0 if kerzen else 2 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/ibkr_gateway_test.sh b/.vault-tools/trading-plattform-pruefung/ibkr_gateway_test.sh deleted file mode 100644 index 6a4957c..0000000 --- a/.vault-tools/trading-plattform-pruefung/ibkr_gateway_test.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/bin/bash -# Erster Anmeldetest am IBKR-Paper-Konto: IB Gateway als Container in WSL, nur lesend. -# Koordinator-Werkzeug, nicht fuers Repo. Pascal am 2026-09-24: Container in WSL, beide Downloads frei -# (ghcr.io/gnzsnz/ib-gateway, festgelegt auf die Pruefsumme unten; nautilus-ibapi im eigenen Venv -# ~/venvs/ibkr-probe), Test nach 22:00, zweiter Faktor bei ihm nur ein Passkey. -# Das Abbild ist dasselbe, das NautilusTrader 1.231.0 mit DockerizedIBGatewayConfig startet -# (config.py: container_image "ghcr.io/gnzsnz/ib-gateway:stable"; gateway.py: Paper aussen 4004). -# Die Zugangsdaten liest der Container aus /home/claude/.config/trading-plattform/ibkr/ (Pascal traegt sie -# mit ibkr_zugang_eintragen.sh ein); dieses Skript gibt sie nie aus und maskiert den Benutzernamen im Log. -# Aufruf aus der PowerShell (sudo per WSLENV, wie bei den anderen Betriebsskripten): -# $env:WSL_PW=[Environment]::GetEnvironmentVariable('DEBIAN_WSL_PASSWORD','User'); $env:WSLENV='WSL_PW' -# wsl -d Debian -u claude -- bash /mnt/d/Claude/.vault-tools/trading-plattform-pruefung/ibkr_gateway_test.sh -# Schritte: laden | pruefen | starten | log | probe | stoppen -set -u -BILD="ghcr.io/gnzsnz/ib-gateway@sha256:5fb04cb93cd705eff8a5a58b015503003864ffd9ea890be4d07273389149aa9f" -ZUGANG=/home/claude/.config/trading-plattform/ibkr -NAME=ibkr-gateway-probe -PROBE_PY=/home/claude/venvs/ibkr-probe/bin/python -PROBE=/mnt/d/Claude/.vault-tools/trading-plattform-pruefung/ibkr_probe.py - -[ -n "${WSL_PW:-}" ] || { echo "WSL_PW fehlt (sudo per WSLENV)"; exit 20; } -S() { printf '%s\n' "$WSL_PW" | sudo -S -p '' "$@"; } - -stunde=$(TZ=Europe/Berlin date +%-H) -# RUHE_AUSNAHME: nur auf Pascals ausdrueckliche Anweisung fuer einen einzelnen Lauf (so am 2026-09-24, -# 17:0x: "Bitte schon einmal testen ohne andere Workflows zu gefaehrden"); der Wert nennt Datum und Anlass. -if [ "$stunde" -ge 15 ] && [ "$stunde" -lt 22 ] && [ -z "${RUHE_AUSNAHME:-}" ]; then - echo "Ruhe-Regel: zwischen 15 und 22 Uhr Berlin laeuft hier nichts (jetzt $(TZ=Europe/Berlin date +%H:%M))." - exit 21 -fi - -maske() { - # Den Benutzernamen und lange Ziffernfolgen aus fremder Ausgabe nehmen, ohne ihn selbst zu zeigen. - python3 -c ' -import re, sys -from pathlib import Path -benutzer = "" -p = Path(sys.argv[1]) -if p.is_file(): - benutzer = p.read_text(encoding="utf-8").strip() -for zeile in sys.stdin: - if benutzer: - zeile = zeile.replace(benutzer, "[benutzer]") - # Kontonummern: ein bis vier Grossbuchstaben und mindestens fuenf Ziffern. Die erste Fassung kannte - # nur DU plus Ziffern; das Paper-Konto hat einen Buchstaben mehr und stand am 2026-09-24 unmaskiert im Log. - zeile = re.sub(r"\b[A-Z]{1,4}\d{5,}\b", "[konto]", zeile) - sys.stdout.write(zeile) -' "$ZUGANG/paper_benutzer" -} - -case "${1:-}" in - laden) - S docker pull "$BILD" 2>&1 | tail -n 3 - S docker image inspect "$BILD" --format 'Id {{.Id}} Groesse {{.Size}} Erstellt {{.Created}}' - ;; - pruefen) - # Welche Variablen liest das Abbild aus Dateien, und unter welcher UID laeuft es? - S docker run --rm --entrypoint sh "$BILD" -c ' - echo "== id"; id - echo "== _FILE-Variablen in den Startskripten"; grep -rhoE "[A-Z_]+_FILE" /home /usr/local/bin /opt 2>/dev/null | sort -u | head -20 - echo "== Ports"; grep -rhoE "4003|4004|4001|4002|5900" /home /usr/local/bin 2>/dev/null | sort | uniq -c | head - ' 2>&1 | head -40 - ;; - starten) - for datei in paper_benutzer paper_passwort; do - [ -s "$ZUGANG/$datei" ] || { echo "fehlt: $ZUGANG/$datei (erst ibkr_zugang_eintragen.sh)"; exit 22; } - done - S docker rm -f "$NAME" >/dev/null 2>&1 - # Benutzername und Passwort aus Dateien im Container; TWS_USERID_FILE nur, wenn "pruefen" es fand, - # sonst setzt BENUTZER_ALS_VARIABLE=1 den Namen als Variable (er ist kein Geheimnis wie das Passwort). - if [ "${BENUTZER_ALS_VARIABLE:-0}" = "1" ]; then - BENUTZER_ARG=(-e "TWS_USERID=$(cat "$ZUGANG/paper_benutzer")") - else - BENUTZER_ARG=(-e "TWS_USERID_FILE=/run/secrets/ibkr/paper_benutzer") - fi - S docker run -d --name "$NAME" \ - -e TRADING_MODE=paper -e READ_ONLY_API=yes -e TWOFA_TIMEOUT_ACTION=exit \ - -e TWS_PASSWORD_FILE=/run/secrets/ibkr/paper_passwort "${BENUTZER_ARG[@]}" \ - -e TIME_ZONE=America/New_York \ - -v "$ZUGANG:/run/secrets/ibkr:ro" \ - -p 127.0.0.1:4004:4004 \ - "$BILD" >/dev/null && echo "gestartet: $NAME (Paper, nur lesend, Port 127.0.0.1:4004)" - ;; - log) - S docker logs --tail "${2:-60}" "$NAME" 2>&1 | maske - ;; - probe) - cd /tmp && "$PROBE_PY" "$PROBE" --port 4004 - ;; - stoppen) - S docker rm -f "$NAME" 2>&1 | tail -n 1 - ;; - *) - echo "Schritt fehlt: laden | pruefen | starten | log | probe | stoppen" - exit 2 - ;; -esac diff --git a/.vault-tools/trading-plattform-pruefung/ibkr_probe.py b/.vault-tools/trading-plattform-pruefung/ibkr_probe.py deleted file mode 100644 index 9ea097b..0000000 --- a/.vault-tools/trading-plattform-pruefung/ibkr_probe.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Lesende Probe gegen das IBKR-Paper-Konto ueber ein laufendes IB Gateway: verbindet, liest, trennt. - -Koordinator-Werkzeug, nicht fuers Repo. Pascal am 2026-09-24: Zugang zum IBKR-Paper-Konto vorbereiten, -Gateway als Container in WSL (ghcr.io/gnzsnz/ib-gateway, wie DockerizedIBGatewayConfig in NautilusTrader -1.231.0), erster Test heute nach 22:00. Diese Probe handelt nie: Das Gateway laeuft mit READ_ONLY_API=yes, -und sie ruft keine Order-Methode. - -Entscheidungsliste (Frage -> Herkunft der Antwort): - 1. Erreicht die Probe das Gateway? Quelle: EClient.connect/isConnected (nautilus-ibapi 10.45.1), - Port 4004 = Paper aussen (gateway.py:43-45 in Nautilus 1.231.0). - 2. Welches Konto ist angemeldet? Quelle: managedAccounts-Rueckruf. Ausgegeben: die ersten zwei - Zeichen und die Laenge (Paper-Konten beginnen im Doku-Beispiel - mit "DU"; das ist ein Beispiel, keine Zusage). - 3. Welche Kontoart, welche Waehrung? Quelle: reqAccountSummary mit den Tags AccountType, Currency; - Betraege (NetLiquidation, TotalCashValue) nur als "vorhanden". - 4. Wie viele Positionen? Quelle: reqPositions -> positionEnd; nur die Zahl. - 5. Kommt ein verzoegerter Kurs? Quelle: reqMarketDataType(4, DELAYED_FROZEN; ohne Abo laut - Nautilus-Doku) und reqMktData(AAPL, SMART, USD, Snapshot). - Ausgegeben: welche Kursart kam (Tick-Typ), nie der Kurs. - 6. Welche Fehler meldet das Gateway? Quelle: error-Rueckruf; Code und gekuerzter Text (ohne Zahlen - ueber vier Stellen). -Eine fehlende Antwort nach ZEITLIMIT_S ist ein Befund, kein Schweigen. Exit 0 alles beantwortet, -1 mindestens ein Befund, 3 keine Verbindung, 2 abgebrochen. -Aufruf (WSL, eigenes Venv): ~/venvs/ibkr-probe/bin/python ibkr_probe.py [--port 4004] [--client-id 991] -""" - -import argparse -import re -import sys -import threading -import time - -ZEITLIMIT_S = 30 -NUR_LESEN_TAGS = "AccountType,Currency,NetLiquidation,TotalCashValue" -BETRAGS_TAGS = {"NetLiquidation", "TotalCashValue"} - - -def maskiere(text: str) -> str: - return re.sub(r"\d{5,}|\d+\.\d+", "[x]", str(text)) - - -def main(argv=None) -> int: - zerleger = argparse.ArgumentParser(allow_abbrev=False) - zerleger.add_argument("--host", default="127.0.0.1") - zerleger.add_argument("--port", type=int, default=4004) - zerleger.add_argument("--client-id", type=int, default=991) - argumente = zerleger.parse_args(argv) - - from ibapi.client import EClient - from ibapi.contract import Contract - from ibapi.wrapper import EWrapper - - class Probe(EWrapper, EClient): - def __init__(self): - EWrapper.__init__(self) - EClient.__init__(self, self) - self.ereignisse = {name: threading.Event() for name in ("konto", "zusammenfassung", "positionen", "kurs", "zeit")} - self.konten = "" - self.zusammenfassung = {} - self.positionen = 0 - self.kurs_arten = set() - self.fehler = [] - self.zeit = None - - def managedAccounts(self, accountsList): - self.konten = accountsList - self.ereignisse["konto"].set() - - def currentTime(self, zeit): - self.zeit = zeit - self.ereignisse["zeit"].set() - - def accountSummary(self, reqId, account, tag, value, currency): - self.zusammenfassung[tag] = "vorhanden" if tag in BETRAGS_TAGS else value - - def accountSummaryEnd(self, reqId): - self.ereignisse["zusammenfassung"].set() - - def position(self, account, contract, position, avgCost): - self.positionen += 1 - - def positionEnd(self): - self.ereignisse["positionen"].set() - - def tickPrice(self, reqId, tickType, price, attrib): - if price and price > 0: - self.kurs_arten.add(tickType) - - def tickSnapshotEnd(self, reqId): - self.ereignisse["kurs"].set() - - def error(self, reqId, *rest): - # Die Signatur wechselt zwischen ibapi-Versionen: nautilus-ibapi 10.45.1 schickt vor dem - # Code einen Zeitstempel in Millisekunden (gemessen am 2026-09-24: 1790261211141 vor 502). - # Fehlercodes sind klein; der Zeitstempel ist es nicht. - werte = [r for r in rest if isinstance(r, (int, str))] - code = next((w for w in werte if isinstance(w, int) and 0 < w < 100000), None) - text = next((w for w in werte if isinstance(w, str) and len(w) > 3), "") - self.fehler.append((reqId, code, maskiere(text)[:160])) - - probe = Probe() - befunde = [] - try: - probe.connect(argumente.host, argumente.port, argumente.client_id) - faden = threading.Thread(target=probe.run, daemon=True) - faden.start() - beginn = time.time() - while not probe.isConnected() and time.time() - beginn < 10: - time.sleep(0.2) - if not probe.isConnected(): - print(f"BEFUND: keine Verbindung zu {argumente.host}:{argumente.port}") - for f in probe.fehler[:5]: - print(f" fehler {f}") - return 3 - print(f"verbunden mit {argumente.host}:{argumente.port}, Serverversion {probe.serverVersion()}") - - probe.reqCurrentTime() - probe.reqAccountSummary(9001, "All", NUR_LESEN_TAGS) - probe.reqPositions() - probe.reqMarketDataType(4) - aapl = Contract() - aapl.symbol, aapl.secType, aapl.exchange, aapl.currency = "AAPL", "STK", "SMART", "USD" - probe.reqMktData(9002, aapl, "", True, False, []) - - for name, ereignis in probe.ereignisse.items(): - if not ereignis.wait(ZEITLIMIT_S): - befunde.append(f"keine Antwort auf {name} in {ZEITLIMIT_S} s") - - konten = [k for k in probe.konten.split(",") if k] - print(f"konten: {len(konten)}; " + ", ".join(f"{k[:2]}… ({len(k)} Zeichen)" for k in konten)) - print(f"serverzeit: {'da' if probe.zeit else 'fehlt'}") - print(f"kontoart: {probe.zusammenfassung.get('AccountType')}, waehrung: {probe.zusammenfassung.get('Currency')}, " - f"nettoliquidation: {probe.zusammenfassung.get('NetLiquidation', 'fehlt')}, bargeld: {probe.zusammenfassung.get('TotalCashValue', 'fehlt')}") - print(f"positionen: {probe.positionen}") - print(f"kursarten AAPL (Tick-Typen mit Preis): {sorted(probe.kurs_arten) or 'keine'}") - for f in probe.fehler: - print(f"gateway meldet: reqId {f[0]}, code {f[1]}: {f[2]}") - if not konten: - befunde.append("kein Konto gemeldet") - if not probe.kurs_arten: - befunde.append("kein verzoegerter Kurs fuer AAPL") - probe.cancelAccountSummary(9001) - probe.cancelPositions() - except Exception as ausnahme: - print(f"BEFUND: Probe abgebrochen: {type(ausnahme).__name__}: {maskiere(ausnahme)[:200]}") - return 2 - finally: - if probe.isConnected(): - probe.disconnect() - for befund in befunde: - print(f"BEFUND: {befund}") - print(f"ERGEBNIS: {len(befunde)} Befund(e)") - return 1 if befunde else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/ibkr_zugang_eintragen.sh b/.vault-tools/trading-plattform-pruefung/ibkr_zugang_eintragen.sh deleted file mode 100644 index 4e45db9..0000000 --- a/.vault-tools/trading-plattform-pruefung/ibkr_zugang_eintragen.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash -# Traegt die Zugangsdaten des IBKR-Paper-Kontos ein. Pascal fuehrt es selbst in seinem Terminal aus; -# kein Agent sieht die Werte. Passwort ohne Anzeige, zweimal zur Kontrolle. -# Ablage: /home/claude/.config/trading-plattform/ibkr/ (Ordner 0700, Dateien 0600, Besitzer claude), -# neben der Geheimnisdatei aus Paket E2. Ausgegeben werden nur Dateinamen und Laengen. -# Koordinator-Werkzeug, nicht fuers Repo. Aufruf aus der PowerShell: -# wsl -d Debian -u claude -- bash /mnt/d/Claude/.vault-tools/trading-plattform-pruefung/ibkr_zugang_eintragen.sh -set -eu -umask 077 -# IBKR_ZIEL nur fuer die Probe des Koordinators mit erfundenen Werten in einem Wegwerf-Ordner. -ZIEL=${IBKR_ZIEL:-/home/claude/.config/trading-plattform/ibkr} -if [ "$(id -un)" != "claude" ]; then - echo "Bitte als Benutzer claude ausfuehren (wsl -d Debian -u claude -- bash ...)." - exit 2 -fi -mkdir -p "$ZIEL" -chmod 700 "$ZIEL" -echo "IBKR-Paper-Konto: Zugangsdaten eintragen (nichts davon verlaesst diesen Rechner)." -read -r -p "Paper-Benutzername: " BENUTZER -read -r -s -p "Paper-Passwort (wird nicht angezeigt): " PASSWORT -echo -read -r -s -p "Passwort noch einmal: " PASSWORT2 -echo -if [ -z "$BENUTZER" ] || [ -z "$PASSWORT" ]; then - echo "Benutzername oder Passwort leer, nichts geschrieben." - exit 2 -fi -if [ "$PASSWORT" != "$PASSWORT2" ]; then - echo "Die beiden Passwoerter stimmen nicht ueberein, nichts geschrieben." - exit 2 -fi -read -r -p "Paper-Kontonummer (beginnt meist mit DU; leer lassen, wenn du sie nicht zur Hand hast): " KONTO -printf '%s' "$BENUTZER" > "$ZIEL/paper_benutzer" -printf '%s' "$PASSWORT" > "$ZIEL/paper_passwort" -if [ -n "$KONTO" ]; then - printf '%s' "$KONTO" > "$ZIEL/paper_konto" -fi -unset PASSWORT PASSWORT2 -chmod 600 "$ZIEL"/* -echo "Gespeichert unter $ZIEL, nur fuer den Benutzer claude lesbar:" -for datei in "$ZIEL"/*; do - printf ' %s: %s Zeichen\n' "$(basename "$datei")" "$(wc -c < "$datei" | tr -d ' ')" -done -echo "Fertig. Aendern: dieses Skript noch einmal ausfuehren. Loeschen: rm -r $ZIEL" diff --git a/.vault-tools/trading-plattform-pruefung/iex_luecken.py b/.vault-tools/trading-plattform-pruefung/iex_luecken.py deleted file mode 100644 index a4731cc..0000000 --- a/.vault-tools/trading-plattform-pruefung/iex_luecken.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Misst Luecken im IEX-Minutenfeed je Symbol und Handelstag. Nur lesend. - -Koordinator-Werkzeug (2026-09-24 nachts, Paket M). Anlass: Der Gratis-Feed IEX liefert fuer weniger bei IEX -gehandelte Werte nicht jede Minute eine Kerze (am 2026-09-23 AMGN 287 von 390). Der Watchdog haelt an, wenn -der letzte Kurs aelter als kursstrom_max_alter_s (300 s) ist; diese Messung zeigt, wie lang die laengste -Minute ohne Kerze je Symbol ist, und wie oft eine Luecke ueber 5 Minuten reicht. Ausgegeben werden nur Zaehler. -Aufruf (WSL, TRADING_GEHEIMNISSE gesetzt): python iex_luecken.py TAG[,TAG...] SYMBOL [SYMBOL ...] -""" - -import sys -import tomllib -from datetime import datetime -from pathlib import Path - -import requests - -sys.path.insert(0, str(Path.cwd())) - -from trading import geheimnisse # noqa: E402 - -DATEN = "https://data.alpaca.markets" - - -def main() -> int: - tage, symbole = sys.argv[1].split(","), sys.argv[2:] - werte = tomllib.loads(geheimnisse.pfad().read_text(encoding="utf-8"))["alpaca"] - s = requests.Session() - s.trust_env = False - s.headers.update({"APCA-API-KEY-ID": werte["key_id"], "APCA-API-SECRET-KEY": werte["secret_key"]}) - for tag in tage: - p = {"symbols": ",".join(symbole), "timeframe": "1Min", "start": f"{tag}T13:30:00Z", - "end": f"{tag}T20:00:00Z", "feed": "iex", "limit": 10000, "adjustment": "raw"} - j = s.get(f"{DATEN}/v2/stocks/bars", params=p, timeout=30).json() - assert not j.get("next_page_token"), "mehr als eine Seite" - print(f"== {tag}") - for sym in symbole: - zeiten = [datetime.fromisoformat(b["t"].replace("Z", "+00:00")) for b in (j.get("bars") or {}).get(sym, [])] - luecken = [int((b - a).total_seconds() // 60) - 1 for a, b in zip(zeiten, zeiten[1:])] - ueber5 = sum(1 for x in luecken if x >= 5) - print(f"{sym:5} Kerzen {len(zeiten):3} laengste Luecke {max(luecken) if luecken else '-':>3} min Luecken >=5 min: {ueber5}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/killswitch_simulation.py b/.vault-tools/trading-plattform-pruefung/killswitch_simulation.py deleted file mode 100644 index 022cf47..0000000 --- a/.vault-tools/trading-plattform-pruefung/killswitch_simulation.py +++ /dev/null @@ -1,509 +0,0 @@ -"""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) diff --git a/.vault-tools/trading-plattform-pruefung/killswitch_simulation_mutanten.py b/.vault-tools/trading-plattform-pruefung/killswitch_simulation_mutanten.py deleted file mode 100644 index 868ee2f..0000000 --- a/.vault-tools/trading-plattform-pruefung/killswitch_simulation_mutanten.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Regressionsnachweis fuer killswitch_simulation.py: Sieht die Simulation einen kaputten Killswitch? - -"0 Befunde" sagt nur etwas, wenn die Pruefungen anschlagen koennen. Dieser Treiber kopiert den Code-Baum in einen -Arbeitsordner, faehrt zuerst die Grundlinie (alle drei Szenarien muessen ohne Befund sein -- sonst misst der Rest nichts), -macht die Kopie dann je Mutant an EINER Stelle kaputt, faehrt das passende Szenario im Schnelltempo und erwartet Exit 2. -Nach jedem Mutanten wird die Datei zurueckgesetzt; am Ende laeuft jedes Szenario noch einmal unveraendert. - -Die Mutanten sind Textersetzungen im Betriebscode. Trifft ein Muster nicht genau einmal -- weil sich der Code geaendert -hat --, ist das ein Befund dieses Treibers ("nicht anwendbar") und kein stiller Durchlass: Dann gehoert das Muster -nachgezogen. - -Zwei Teile, weil ein Werkzeugaufruf der Sitzung hoechstens zehn Minuten laufen darf: Teil 1 faehrt die Grundlinie und die -ersten drei Mutanten, Teil 2 die letzten drei und die Gegenprobe nach dem Zuruecksetzen. Ein Lauf im Schnelltempo dauert -rund eine halbe Minute. - -Kein Netz, kein echtes Konto: Gerufen wird nur killswitch_simulation.py. -Aufruf (WSL, Venv des Projekts): - python killswitch_simulation_mutanten.py <1|2> -Exit 0: jeder Mutant gesehen, Grundlinie und Gegenprobe sauber. Exit 1: sonst. -""" - -import shutil -import subprocess -import sys -from pathlib import Path - -TEMPO = "0.05" -SIMULATION = Path(__file__).resolve().parent / "killswitch_simulation.py" - -MUTANTEN = [ - ("K1 der Runner uebergeht den Killswitch", "hand", "trading/paper/sitzung.py", - ' self._zustand_text = (\n gespeichert.zustand if gespeichert is not None else "angehalten"\n )\n', - ' self._zustand_text = "aktiv"\n'), - ("K2 Glattstellen schliesst die gespielte Position nicht", "hand", "trading/paper/generalprobe.py", - " self._broker.storniere_alle()\n self._broker.schliesse_alle()\n", - " self._broker.storniere_alle()\n"), - # 0.02 ist der Wert aus config/risk.toml: Die engere Grenze darf nur verschaerfen; ein groesserer Wert braeche schon - # beim Laden ab und waere dann ein Abbruch statt eines uebersehenen Sturzes. - ("K3 die Tagesverlustgrenze greift nicht", "sturz", "config/paper.toml", - "max_tagesverlust_anteil = 0.002", "max_tagesverlust_anteil = 0.02"), - ("K4 jeder Ausloeser zaehlt als Klick", "neustart", "trading/paper/sitzung.py", - " if gespeichert.ausloeser != KLICK_AUSLOESER:\n", " if False:\n"), - ("K5 keine Pausenmeldung beim Neustart", "neustart", "trading/paper/sitzung.py", - " if self._ausloeser_text == MENSCH:\n return\n", " return\n"), - ("K6 der Tagesverlust haelt nur an, statt glattzustellen", "sturz", "trading/paper/sitzung.py", - " if grund == TAGESVERLUST:\n self._glattstellen(grund)\n else:\n self._anhalten(grund)\n", - " self._anhalten(grund)\n"), -] - - -def main() -> int: - if len(sys.argv) != 6 or sys.argv[5] not in ("1", "2"): - print(__doc__) - return 1 - quelle = Path(sys.argv[1]).resolve() - echt = Path(sys.argv[2]).resolve() - kerzen = Path(sys.argv[3]).resolve() - arbeit = Path(sys.argv[4]).resolve() - teil = sys.argv[5] - kopie = arbeit / "kopie" - - def fahre(szenario: str, lauf: str) -> tuple[int, str]: - ziel = arbeit / lauf - if ziel.exists(): - shutil.rmtree(ziel) - ergebnis = subprocess.run( - [sys.executable, str(SIMULATION), szenario, str(kopie), str(echt), str(kerzen), str(ziel), TEMPO], - capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=600, - env={**__import__("os").environ, "PYTHONDONTWRITEBYTECODE": "1"}, - ) - (arbeit / f"{lauf}.txt").write_text(ergebnis.stdout + ergebnis.stderr, encoding="utf-8") - zeilen = (ergebnis.stdout + ergebnis.stderr).strip().splitlines() - befundzeile = next((z for z in reversed(zeilen) if "Befund(e)" in z), zeilen[-1] if zeilen else "") - return ergebnis.returncode, befundzeile[:500] - - arbeit.mkdir(parents=True, exist_ok=True) - if kopie.exists(): - shutil.rmtree(kopie) - shutil.copytree(quelle, kopie, ignore=shutil.ignore_patterns(".git", "node_modules", "dist", "__pycache__", ".pytest_cache")) - - gestoert = [] - if teil == "1": - for szenario in ("hand", "sturz", "neustart"): - code, befundzeile = fahre(szenario, f"grundlinie-{szenario}") - print(f"Grundlinie [{szenario}]: exit={code} -- {befundzeile}") - if code != 0: - print("Abbruch: Die Grundlinie ist nicht sauber; die Mutanten wuerden nichts messen.") - return 1 - - for nummer, (name, szenario, datei, alt, neu) in enumerate(MUTANTEN, start=1): - if (nummer <= 3) != (teil == "1"): - continue - pfad = kopie / datei - original = pfad.read_text(encoding="utf-8") - if original.count(alt) != 1: - print(f"{name}: Muster steht {original.count(alt)}-mal -- NICHT ANWENDBAR, Muster nachziehen") - gestoert.append(name + " (nicht anwendbar)") - continue - with open(pfad, "w", encoding="utf-8", newline="\n") as strom: - strom.write(original.replace(alt, neu)) - try: - code, befundzeile = fahre(szenario, f"mutant-{nummer}") - finally: - with open(pfad, "w", encoding="utf-8", newline="\n") as strom: - strom.write(original) - urteil = "gesehen" if code == 2 else "UEBERSEHEN" if code == 0 else f"ABGEBROCHEN (exit {code})" - print(f"{name} [{szenario}]: {urteil} -- {befundzeile}") - if code != 2: - gestoert.append(name) - - if teil == "2": - for szenario in ("hand", "sturz", "neustart"): - code, befundzeile = fahre(szenario, f"gegenprobe-{szenario}") - print(f"Gegenprobe nach dem Zuruecksetzen [{szenario}]: exit={code} -- {befundzeile}") - if code != 0: - gestoert.append(f"Gegenprobe {szenario}") - - print(f"Teil {teil}: nicht gesehen oder gestoert: {gestoert}") - return 1 if gestoert else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/kurs_unter_grenze.py b/.vault-tools/trading-plattform-pruefung/kurs_unter_grenze.py deleted file mode 100644 index 2e185e9..0000000 --- a/.vault-tools/trading-plattform-pruefung/kurs_unter_grenze.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Prüft für eine Liste von Symbolen den letzten Tagesschluss gegen eine Kursgrenze. Nur lesend. - -Koordinator-Werkzeug, nicht Teil des Repos. Anlass: Paket M (zehn Aktien, 2026-09-25) — eine Order -darf höchstens max_notional_je_order (10.000 USD) haben, ein Bein ist basis_menge (10) Stück, also -muss jeder Kurs unter 1.000 USD liegen. Einziger Netzzugriff: ein lesender Abruf der Tageskerzen bei -Alpaca (IEX). Ausgegeben werden Symbol, Datum der Kerze, Schlusskurs (öffentlicher Börsenkurs, kein -Depotwert) und ob er unter der Grenze liegt; nie ein Schlüssel. -Aufruf (WSL, aus dem Projektordner, TRADING_GEHEIMNISSE gesetzt): - python kurs_unter_grenze.py 1000 AAPL MSFT NVDA ... -""" - -import sys -import tomllib -from pathlib import Path - -import requests - -sys.path.insert(0, str(Path.cwd())) - -from trading import geheimnisse # noqa: E402 - -DATEN_URL = "https://data.alpaca.markets" - - -def main() -> int: - grenze = float(sys.argv[1]) - symbole = sys.argv[2:] - werte = tomllib.loads(geheimnisse.pfad().read_text(encoding="utf-8"))["alpaca"] - sitzung = requests.Session() - sitzung.trust_env = False - antwort = sitzung.get( - f"{DATEN_URL}/v2/stocks/bars/latest", - params={"symbols": ",".join(symbole), "feed": "iex"}, - headers={"APCA-API-KEY-ID": werte["key_id"], "APCA-API-SECRET-KEY": werte["secret_key"]}, - timeout=20, - ) - print("HTTP", antwort.status_code) - if antwort.status_code != 200: - print(antwort.text[:300]) - return 1 - bars = antwort.json().get("bars", {}) - fehler = 0 - for symbol in symbole: - bar = bars.get(symbol) - if not bar: - print(f"{symbol:6} keine Kerze") - fehler += 1 - continue - schluss = float(bar["c"]) - ok = schluss < grenze - fehler += 0 if ok else 1 - print(f"{symbol:6} {bar['t'][:16]} {schluss:9.2f} {'unter' if ok else 'UEBER'} {grenze:.0f}") - return 0 if fehler == 0 else 2 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/latenz_auswertung.py b/.vault-tools/trading-plattform-pruefung/latenz_auswertung.py deleted file mode 100644 index 8697858..0000000 --- a/.vault-tools/trading-plattform-pruefung/latenz_auswertung.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Rechnet Reaktionszeiten aus dem, was ein Lauf des Paper-Runners ohnehin hinterlaesst -- nur lesend. - -Koordinator-Werkzeug fuer Tage, an denen der Runner seine Latenzen noch nicht selbst misst (vor Paket L). -Quellen: das JSONL-Protokoll des Laufs (jede Zeile traegt echte Zeit, auch in der Generalprobe) und die -Tabelle killswitch_ereignisse im Register (Zeitpunkt jedes Klicks). Das Register wird mit mode=ro geoeffnet. - -Gerechnet wird: - Klick -> Runner reagiert Registerereignis (Ausloeser pascal, erfolgreich) bis zur ersten Taktzeile - danach, die den neuen Zustand traegt. Die Taktzeile entsteht am ENDE des - Takts -- der Wert ist eine obere Schranke fuer "gelesen" und sagt, ab wann - der neue Zustand im Protokoll wirkt. - Kerze -> Taktende nur im Modus paper: Taktzeile mit frischer Kerze und Aktion, Zeit der Zeile - minus (juengste Kerze + 60 s). Obere Schranke fuer Kerze -> Entscheidung, - weil die Zeile erst nach den Orders geschrieben wird. In der Generalprobe - sinnlos (Kerzen von einem anderen Tag) und deshalb ausgelassen. - Taktabstand Abstand aufeinanderfolgender Taktzeilen -- der Rahmen fuer alles andere. - -Nicht gerechnet: Fuellzeit (dafuer handelstag_auswertung.py --broker) und Meldungen (ntfy_lesen.py). -Gibt nie einen Kontowert aus. - -Aufruf (WSL, aus dem Projektordner): - python latenz_auswertung.py [] -""" - -import json -import sqlite3 -import statistics -import sys -from datetime import datetime -from datetime import timedelta -from pathlib import Path - -NEUER_ZUSTAND = {"anhalten": "angehalten", "glattstellen": "gesperrt", "fortsetzen": "aktiv", "freigeben": "aktiv"} -KLICKER = "pascal" - - -def zeit(text: str) -> datetime: - return datetime.fromisoformat(text) - - -def kennzahlen(name: str, werte: list[float], einheit: str = "s") -> None: - if not werte: - print(f" {name}: keine Messung") - return - sortiert = sorted(werte) - p90 = sortiert[min(len(sortiert) - 1, int(round(0.9 * (len(sortiert) - 1))))] - print( - f" {name}: n={len(werte)} Median {statistics.median(werte):.1f} {einheit} " - f"Mittel {statistics.fmean(werte):.1f} p90 {p90:.1f} Min {sortiert[0]:.1f} Max {sortiert[-1]:.1f}" - ) - - -def main() -> int: - protokoll = Path(sys.argv[1]) - register = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("data/registry.db") - - zeilen = [] - with open(protokoll, encoding="utf-8") as strom: # Dateiiteration, nie splitlines (U+2028) - for roh in strom: - if roh.strip(): - zeilen.append(json.loads(roh)) - kopf = next((z for z in zeilen if z.get("art") == "kopf"), {}) - takte = [z for z in zeilen if z.get("art") == "takt"] - modus = kopf.get("modus", "unbekannt") - if not takte: - print("Keine Taktzeile im Protokoll.") - return 1 - start, ende = zeit(zeilen[0]["zeit"]), zeit(zeilen[-1]["zeit"]) - print(f"Protokoll {protokoll.name}: Modus {modus}, {len(takte)} Takte, {start:%H:%M:%S} bis {ende:%H:%M:%S} UTC") - - abstaende = [ - (zeit(b["zeit"]) - zeit(a["zeit"])).total_seconds() for a, b in zip(takte, takte[1:]) - ] - print("Rahmen:") - kennzahlen("Taktabstand", abstaende) - - print("Klick -> Runner reagiert (Register gegen erste Taktzeile mit dem neuen Zustand):") - if not register.exists(): - print(f" Register {register} fehlt -- ausgelassen") - else: - verbindung = sqlite3.connect(f"file:{register.as_posix()}?mode=ro", uri=True) - try: - ereignisse = verbindung.execute( - "SELECT zeit, stufe, ausloeser, erfolgreich FROM killswitch_ereignisse ORDER BY id" - ).fetchall() - finally: - verbindung.close() - reaktionen = [] - for ereignis_zeit, stufe, ausloeser, erfolgreich in ereignisse: - wann = zeit(ereignis_zeit) - if not (start <= wann <= ende): - continue - if ausloeser != KLICKER: - print(f" {wann:%H:%M:%S} {stufe} von {ausloeser}: kein Klick, ausgelassen") - continue - if not erfolgreich: - print(f" {wann:%H:%M:%S} {stufe}: nicht erfolgreich, ausgelassen") - continue - neu = NEUER_ZUSTAND.get(stufe) - treffer = next( - (t for t in takte if zeit(t["zeit"]) > wann and t.get("killswitch") == neu), None - ) - if treffer is None: - print(f" {wann:%H:%M:%S} {stufe}: keine Taktzeile mit Zustand {neu} danach") - continue - sekunden = (zeit(treffer["zeit"]) - wann).total_seconds() - reaktionen.append(sekunden) - print(f" {wann:%H:%M:%S} {stufe:<13} -> Takt {treffer.get('schritt')} ({treffer.get('phase')}) nach {sekunden:5.1f} s") - kennzahlen("Klick -> Reaktion", reaktionen) - - if modus == "paper": - print("Kerze -> Taktende (obere Schranke fuer Kerze -> Entscheidung):") - werte = [] - for takt in takte: - if takt.get("frische_kerze") and takt.get("aktion") is not None and takt.get("letzte_kerze"): - schluss = zeit(takt["letzte_kerze"]) + timedelta(seconds=60) - werte.append((zeit(takt["zeit"]) - schluss).total_seconds()) - kennzahlen("Kerze -> Taktende", werte) - ohne = sum(1 for takt in takte if takt.get("markt_offen") and not takt.get("frische_kerze")) - print(f" Takte bei offenem Markt ohne frische Kerze: {ohne} von {len(takte)}") - else: - print("Kerze -> Entscheidung: im Modus", modus, "nicht gerechnet (Kerzen eines anderen Tages).") - - orders = [z for z in zeilen if z.get("art") == "order"] - meldefehler = [z for z in zeilen if z.get("art") == "ereignis" and z.get("was") == "benachrichtigung_fehlgeschlagen"] - print(f"Dazu: {len(orders)} Orderzeile(n), {len(meldefehler)} fehlgeschlagene Meldung(en).") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/lauf_pruef.sh b/.vault-tools/trading-plattform-pruefung/lauf_pruef.sh deleted file mode 100644 index f9d444e..0000000 --- a/.vault-tools/trading-plattform-pruefung/lauf_pruef.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash -# Startet ein Python-Skript aus dem Scratchpad im echten Projektordner, mit Argumenten. -# Aufruf: wsl -d Debian -- bash -s -- [argumente...] < lauf_pruef.sh -set -u -SCRATCH="/mnt/d/Claude/.vault-tools/trading-plattform-pruefung" -cd "/mnt/d/Claude/Code Projekte/active - Trading Plattform" || exit 9 -SKRIPT="$1" -shift -PYTHONPATH=. timeout 560 "$HOME/venvs/trading/bin/python" "$SCRATCH/$SKRIPT" "$@" < /dev/null 2> /tmp/lauf_pruef_stderr.txt -echo "EXIT=$?" -grep -v -E "Pandas4Warning|utcnow|warnings.warn" /tmp/lauf_pruef_stderr.txt | tail -8 diff --git a/.vault-tools/trading-plattform-pruefung/lauf_ws_probe.sh b/.vault-tools/trading-plattform-pruefung/lauf_ws_probe.sh deleted file mode 100644 index 6453cd6..0000000 --- a/.vault-tools/trading-plattform-pruefung/lauf_ws_probe.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash -# Startet das Probe-Backend auf Port 8145 (Takt 1 s), faehrt pruef_ws.py dagegen, beendet das Backend. -set -u -SCRATCH="/mnt/d/Claude/.vault-tools/trading-plattform-pruefung" -cd "/mnt/d/Claude/Code Projekte/active - Trading Plattform" || exit 9 -PY="$HOME/venvs/trading/bin/python" -PYTHONPATH=. "$PY" "$SCRATCH/start_probe_backend.py" 8145 1.0 < /dev/null > /tmp/probe_backend.log 2>&1 & -BACKEND=$! -for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do - if "$PY" -c "import socket; s=socket.create_connection(('127.0.0.1', 8145), 0.5); s.close()" 2>/dev/null; then break; fi - sleep 1 -done -PYTHONPATH=. timeout 60 "$PY" "$SCRATCH/pruef_ws.py" 8145 < /dev/null -echo "EXIT_PRUEF=$?" -kill "$BACKEND" 2>/dev/null -wait "$BACKEND" 2>/dev/null -echo "--- Backend-Protokoll ---" -grep -v -E "Pandas4Warning|utcnow" /tmp/probe_backend.log | tail -5 diff --git a/.vault-tools/trading-plattform-pruefung/lies_paper_protokoll.py b/.vault-tools/trading-plattform-pruefung/lies_paper_protokoll.py deleted file mode 100644 index 8e9db6d..0000000 --- a/.vault-tools/trading-plattform-pruefung/lies_paper_protokoll.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Liest ein Paper- oder Probe-Protokoll mit eigenem Code und fasst zusammen, was darin steht. - -Koordinator-Werkzeug zu Teil 10, nicht fuers Repo. Es tut etwas und beurteilt wenig: Die Zahlen liest -der Koordinator selbst. Binaer gelesen, nur an b"\\n" getrennt. - -Aufruf: python lies_paper_protokoll.py -""" - -import json -import sys -from collections import Counter -from pathlib import Path - -zeilen = [json.loads(z) for z in Path(sys.argv[1]).read_bytes().split(b"\n") if z.strip()] -arten = Counter(z.get("art") for z in zeilen) -print("zeilen:", len(zeilen), dict(arten)) - -kopf = next(z for z in zeilen if z.get("art") == "kopf") -print("kopf:", {k: kopf.get(k) for k in ("modus", "agent", "instrument", "fenster", "basis_menge", "feed", "glatt_vor_schluss_min")}) - -takte = [z for z in zeilen if z.get("art") == "takt"] -print("takte:", len(takte), "phasen:", dict(Counter(t.get("phase") for t in takte))) -print("killswitch in takten:", dict(Counter(t.get("killswitch") for t in takte))) -print("frische kerze:", dict(Counter(t.get("frische_kerze") for t in takte))) -erste_entscheidung = next((t for t in takte if t.get("aktion") is not None), None) -if erste_entscheidung: - print("erste entscheidung: schritt", erste_entscheidung["schritt"], "kerzen", erste_entscheidung["kerzen"], "letzte_kerze", erste_entscheidung["letzte_kerze"], "aktion", erste_entscheidung["aktion"], "ziel", erste_entscheidung["zielposition"]) -print("aktionen:", dict(Counter(t.get("aktion") for t in takte))) -print("positionen:", dict(Counter(t.get("position") for t in takte))) -schliesst = [t for t in takte if t.get("phase") == "schliesst"] -if schliesst: - print("schlussphase: von", schliesst[0]["letzte_kerze"], "bis", schliesst[-1]["letzte_kerze"], "positionen", [t["position"] for t in schliesst]) - -for order in (z for z in zeilen if z.get("art") == "order"): - print("order:", {k: order.get(k) for k in ("seite", "menge", "gefuellt", "status", "position_danach")}) -for ereignis in (z for z in zeilen if z.get("art") == "ereignis"): - print("ereignis:", ereignis.get("was"), "|", str(ereignis.get("text"))[:140]) -ende = [z for z in zeilen if z.get("art") == "ende"] -print("ende:", ende) -hinweise = Counter(t.get("hinweis") for t in takte if t.get("hinweis")) -print("hinweise in takten:", dict(hinweise)) diff --git a/.vault-tools/trading-plattform-pruefung/m_probeaufruf.py b/.vault-tools/trading-plattform-pruefung/m_probeaufruf.py deleted file mode 100644 index 347eaec..0000000 --- a/.vault-tools/trading-plattform-pruefung/m_probeaufruf.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Lesender Probeaufruf fuer Paket M: GET /v2/assets, trades/latest und bars mit mehreren Symbolen. - -Koordinator-Werkzeug, nicht Teil des Repos. Anlass: Paket M (zehn Aktien, 2026-09-25) haengt an drei -Antwortformen, die im Projekt nie gemessen wurden -- GET /v2/assets/{symbol}, GET /v2/stocks/trades/latest -mit mehreren Symbolen und das Seitenverhalten von GET /v2/stocks/bars mit mehreren Symbolen. Das Werkzeug -belegt sie an genau dem Code, der sie liest (AlpacaBroker.anlage, AlpacaBroker.letzte_kurse, -AlpacaKerzenquelle.kerzen_mehrerer), und zusaetzlich roh: Schluessel und Typen, nie Werte ausser den -Wahrheitswerten der Startpruefung, Zaehlungen und dem Alter des letzten Trades. -Nur GET, kein schreibender Aufruf. Liest die Geheimnisdatei ueber den Aufloeser des Projekts -(TRADING_GEHEIMNISSE, sonst config/secrets.toml) und gibt die Schluessel nie aus. -Aufruf (WSL, aus einem Arbeitsbaum mit Paket M, Venv): - python m_probeaufruf.py [ ...] -Exit 0: alle Formen wie angenommen. 2: eine Abweichung oder ein Starthindernis. 1: Ausnahme. -""" - -import sys -import tomllib -from datetime import UTC -from datetime import datetime -from pathlib import Path - -import requests - -sys.path.insert(0, str(Path.cwd())) - -from trading import geheimnisse # noqa: E402 -from trading.broker.alpaca import DATEN_URL # noqa: E402 -from trading.broker.alpaca import TRADING_URL # noqa: E402 -from trading.broker.alpaca import AlpacaBroker # noqa: E402 -from trading.broker.schnittstelle import zu_symbol # noqa: E402 -from trading.paper.kerzen import HANDELSZONE # noqa: E402 -from trading.paper.kerzen import AlpacaKerzenquelle # noqa: E402 -from trading.paper.kerzen import handelsbeginn # noqa: E402 -from trading.paper.kerzen import handelsende # noqa: E402 - -FEED = "iex" -# Klein genug, dass zehn Symbole mal 390 Minuten mehrere Seiten brauchen. -KLEINE_SEITE = 1000 -MAX_SEITEN = 50 - - -def typ(wert) -> str: - return type(wert).__name__ - - -def iso_z(zeitpunkt: datetime) -> str: - return zeitpunkt.astimezone(UTC).isoformat().replace("+00:00", "Z") - - -def zaehle_seiten(sitzung, kopf: dict, parameter: dict) -> tuple[dict[str, int], int, int]: - """Kerzen je Symbol ueber alle Seiten, Zahl der Seiten, Seiten mit mehr als einem Symbol.""" - parameter = dict(parameter) - je_symbol: dict[str, int] = {} - seiten = 0 - gemischt = 0 - while seiten < MAX_SEITEN: - antwort = sitzung.get(f"{DATEN_URL}/v2/stocks/bars", headers=kopf, params=parameter, timeout=30) - seiten += 1 - if antwort.status_code != 200: - raise RuntimeError(f"GET /v2/stocks/bars antwortete {antwort.status_code}") - nutzlast = antwort.json() - bars = nutzlast.get("bars") or {} - if len(bars) > 1: - gemischt += 1 - for symbol, liste in bars.items(): - je_symbol[symbol] = je_symbol.get(symbol, 0) + len(liste or []) - token = nutzlast.get("next_page_token") - if not token: - break - parameter["page_token"] = token - return je_symbol, seiten, gemischt - - -def main() -> int: - if len(sys.argv) < 3: - print(__doc__) - return 1 - datum = sys.argv[1] - kennungen = sys.argv[2:] - symbole = [zu_symbol(kennung) for kennung in kennungen] - werte = tomllib.loads(geheimnisse.pfad().read_text(encoding="utf-8"))["alpaca"] - kopf = {"APCA-API-KEY-ID": werte["key_id"], "APCA-API-SECRET-KEY": werte["secret_key"]} - sitzung = requests.Session() - sitzung.trust_env = False - abweichungen = 0 - - # 1. GET /v2/assets roh fuer das erste Symbol: nur Schluessel und Typen. - antwort = sitzung.get(f"{TRADING_URL}/v2/assets/{symbole[0]}", headers=kopf, timeout=20) - print(f"GET /v2/assets/{symbole[0]}: HTTP {antwort.status_code}") - roh = antwort.json() if antwort.status_code == 200 else {} - if isinstance(roh, dict): - print(f" schluessel und typen: {dict((k, typ(v)) for k, v in sorted(roh.items()))}") - for feld, erwartet in (("symbol", "str"), ("tradable", "bool"), ("shortable", "bool"), ("easy_to_borrow", "bool")): - gefunden = typ(roh.get(feld)) if isinstance(roh, dict) and feld in roh else "fehlt" - if gefunden != erwartet: - print(f"ABWEICHUNG: Feld {feld} ist {gefunden}, erwartet {erwartet}") - abweichungen += 1 - - # 2. anlage() je Instrument ueber den Code des Projekts. - broker = AlpacaBroker( - werte["key_id"], werte["secret_key"], universum=tuple(kennungen), feed=FEED, timeout=20.0 - ) - for kennung in kennungen: - anlage = broker.anlage(kennung) - print( - f"anlage {kennung}: tradable {anlage.handelbar}, shortable {anlage.leerverkaufbar}, " - f"easy_to_borrow {anlage.leicht_leihbar}" - ) - if not (anlage.handelbar and anlage.leerverkaufbar and anlage.leicht_leihbar): - print(f"STARTHINDERNIS: {kennung} haelt die Startpruefung einer Folge mit Short nicht") - abweichungen += 1 - - # 3. trades/latest roh und ueber den Code. - antwort = sitzung.get( - f"{DATEN_URL}/v2/stocks/trades/latest", - headers=kopf, - params={"symbols": ",".join(symbole), "feed": FEED}, - timeout=20, - ) - print(f"GET /v2/stocks/trades/latest mit {len(symbole)} Symbolen: HTTP {antwort.status_code}") - roh = antwort.json() if antwort.status_code == 200 else {} - trades = roh.get("trades") if isinstance(roh, dict) else None - print(f" schluessel oben: {sorted(roh) if isinstance(roh, dict) else typ(roh)}, trades ist {typ(trades)}") - if not isinstance(trades, dict) or sorted(trades) != sorted(symbole): - gesehen = sorted(trades) if isinstance(trades, dict) else typ(trades) - print(f"ABWEICHUNG: trades traegt {gesehen} statt {sorted(symbole)}") - abweichungen += 1 - kurse = broker.letzte_kurse(kennungen) - jetzt = datetime.now(UTC) - for kennung in kennungen: - kurs = kurse.get(kennung) - alter = "keiner" if kurs is None else f"vor {(jetzt - kurs.zeit).total_seconds():.0f} s" - print(f"letzter trade {kennung}: {alter}") - - # 4. Kerzen des Handelstags: gebuendelt ueber den Code gegen einzeln ueber den Code. - mitternacht = datetime.strptime(datum, "%Y-%m-%d").replace(tzinfo=HANDELSZONE) - bis = handelsende(mitternacht) - quelle = AlpacaKerzenquelle(werte["key_id"], werte["secret_key"], feed=FEED, timeout=30.0) - gebuendelt = quelle.kerzen_mehrerer(kennungen, bis) - for kennung in kennungen: - einzeln = quelle.kerzen_des_tages(kennung, bis) - gleich = [k.zeit for k in einzeln] == [k.zeit for k in gebuendelt[kennung]] - print( - f"kerzen {kennung}: gebuendelt {len(gebuendelt[kennung])}, einzeln {len(einzeln)}" - + ("" if gleich else " ABWEICHUNG") - ) - if not gleich: - abweichungen += 1 - - # 5. Seitenverhalten roh: eine grosse Seite gegen viele kleine, je Symbol dieselbe Summe. - grundparameter = { - "symbols": ",".join(symbole), - "timeframe": "1Min", - "start": iso_z(handelsbeginn(bis)), - "end": iso_z(bis), - "feed": FEED, - "adjustment": "raw", - } - gross, seiten_gross, _ = zaehle_seiten(sitzung, kopf, grundparameter | {"limit": 10_000}) - klein, seiten_klein, gemischt = zaehle_seiten(sitzung, kopf, grundparameter | {"limit": KLEINE_SEITE}) - summe = sum(gross.values()) - print(f"seiten mit limit 10000: {seiten_gross}, kerzen zusammen {summe}") - print(f"seiten mit limit {KLEINE_SEITE}: {seiten_klein}, davon mit mehr als einem symbol: {gemischt}") - if gross != klein: - print(f"ABWEICHUNG: je Symbol ueber die kleinen Seiten {klein}, ueber die grosse {gross}") - abweichungen += 1 - if summe > KLEINE_SEITE and seiten_klein == 1: - print("HINWEIS: limit gilt offenbar je Symbol und nicht ueber alle; der Code sammelt in beiden Faellen richtig") - - if abweichungen: - print(f"ERGEBNIS: {abweichungen} Abweichung(en) oder Starthindernis(se)") - return 2 - print("ERGEBNIS: alle Formen wie angenommen") - return 0 - - -if __name__ == "__main__": - try: - sys.exit(main()) - except Exception as ausnahme: # noqa: BLE001 - ein Absturz ist selbst ein Befund - print(f"BEFUND: das Werkzeug ist abgebrochen: {ausnahme!r}") - sys.exit(1) diff --git a/.vault-tools/trading-plattform-pruefung/mutanten_py.py b/.vault-tools/trading-plattform-pruefung/mutanten_py.py deleted file mode 100644 index 761394d..0000000 --- a/.vault-tools/trading-plattform-pruefung/mutanten_py.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Mutantenlauf auf der materialisierten Kopie: Schlaegt jeder neue Test an, wenn sein Gegenstand kaputt ist? - -Laeuft in WSL; Aufruf: python mutanten_py.py . Je Mutant: Datei als Bytes sichern, genau eine Stelle ersetzen, die genannten Tests -fahren, Datei aus der Sicherung zurueckspielen. Erwartet ist ROT; ein gruener Mutant ist ein Befund. -Zuerst die Grundlinie: Ohne Mutant muessen dieselben Tests gruen sein. -""" - -import subprocess -import sys -from pathlib import Path - -MAT = Path(sys.argv[1]) # WSL-Pfad der Kopie mit dem angewendeten Plan -PY = str(Path.home() / "venvs/trading/bin/python") - -MUTANTEN = [ - ( - "lies_lauf trennt mit der Zeilentrennung von str", - "trading/dashboard/laeufe.py", - ' with pfad.open("rb") as strom:\n for roh in strom:\n if not roh.strip():', - ' with pfad.open("rb") as strom:\n for roh in [z.encode("utf-8") for z in strom.read().decode("utf-8").splitlines()]:\n if not roh.strip():', - ["tests/test_dashboard_laeufe.py::test_die_einzelansicht_trennt_nicht_an_u2028"], - ), - ( - "_ist_protokolldatei laesst alles durch", - "trading/dashboard/laeufe.py", - " return pfad.is_file() and not pfad.is_symlink()", - " return True", - [ - "tests/test_dashboard_laeufe.py::test_ein_ordner_mit_der_endung_jsonl_ist_kein_lauf", - "tests/test_dashboard_laeufe.py::test_eine_symbolische_verknuepfung_ist_kein_lauf", - ], - ), - ( - "_ist_protokolldatei folgt Verknuepfungen", - "trading/dashboard/laeufe.py", - " return pfad.is_file() and not pfad.is_symlink()", - " return pfad.is_file()", - ["tests/test_dashboard_laeufe.py::test_eine_symbolische_verknuepfung_ist_kein_lauf"], - ), - ( - "letzte_zeilen gibt ein Bruchstueck ohne Trenner zurueck", - "trading/dashboard/laeufe.py", - " if not trenner:\n return []", - " if not trenner:\n return [_bruchstueck]", - ["tests/test_dashboard_laeufe.py::test_eine_letzte_zeile_ueber_dem_block_ohne_zeilenende_liefert_nichts"], - ), - ( - "Eimeranfang mit festem Stundenschritt", - "trading/dashboard/markt.py", - " anfang_min = HANDELSSTART_MIN + nummer * aufloesung_min", - " anfang_min = HANDELSSTART_MIN + nummer * 60", - ["tests/test_dashboard_markt.py::test_15_und_5_minuten_teilen_den_handelstag_ohne_rest"], - ), - ( - "Tagesachse in der Achsenzone statt in der Handelszone", - "trading/dashboard/markt.py", - " return zeitpunkt.tz_convert(HANDELSZONE).date().isoformat()", - " return zeitpunkt.tz_convert(zone).date().isoformat()", - ["tests/test_dashboard_markt.py::test_eine_tageskerze_bekommt_den_new_yorker_kalendertag_als_string"], - ), - ( - "Tagesachse in UTC", - "trading/dashboard/markt.py", - " return zeitpunkt.tz_convert(HANDELSZONE).date().isoformat()", - " return zeitpunkt.date().isoformat()", - ["tests/test_dashboard_markt.py::test_eine_tageskerze_bekommt_den_new_yorker_kalendertag_als_string"], - ), - ( - "Handelstag mit 391 Kerzen", - "trading/dashboard/markt.py", - "KERZEN_JE_HANDELSTAG = 390", - "KERZEN_JE_HANDELSTAG = 391", - ["tests/test_dashboard_markt.py::test_der_handelstag_hat_390_minuten_und_die_lesespanne_folgt_daraus"], - ), - ( - "Kalenderfaktor 1,5 statt 1,6", - "trading/dashboard/markt.py", - "KALENDERFAKTOR_MINUTEN = 1.6", - "KALENDERFAKTOR_MINUTEN = 1.5", - ["tests/test_dashboard_markt.py::test_der_handelstag_hat_390_minuten_und_die_lesespanne_folgt_daraus"], - ), - ( - "lies_kerzen liest die Metadaten ungeschuetzt", - "trading/dashboard/markt.py", - " meta = _meta(katalog, bar_type)\n bis = _bis(ende, meta)", - " meta = katalog.meta(bar_type)\n bis = _bis(ende, meta)", - ["tests/test_dashboard_markt.py::test_lies_kerzen_uebersteht_eine_unlesbare_metadatei"], - ), - ( - "katalog() prueft nur die Wurzel", - "trading/dashboard/app.py", - ' if not (wurzel / "_meta").is_dir():', - " if not wurzel.is_dir():", - ["tests/test_dashboard_endpunkte.py::test_ein_ordner_ohne_meta_ist_kein_katalog_und_bekommt_keines"], - ), - ( - "_ersetze laesst den alten Snapshot liegen", - "trading/dashboard/verteiler.py", - " if warteschlange.full():\n try:", - " if warteschlange.full():\n return\n if warteschlange.full():\n try:", - ["tests/test_dashboard_verteiler.py::test_eine_volle_warteschlange_bekommt_den_neuen_snapshot"], - ), - ( - "killswitch_uebung prueft den Schalter erst nach der Konfiguration", - "scripts/killswitch_uebung.py", - " if not parse_args().ausfuehren:\n print(OHNE_SCHALTER, file=sys.stderr)\n return 2\n\n limits = lade_limits(\"config/risk.toml\")", - " limits = lade_limits(\"config/risk.toml\")\n if not parse_args().ausfuehren:\n print(OHNE_SCHALTER, file=sys.stderr)\n return 2\n", - [ - "tests/test_skripte_starten.py::test_die_killswitch_uebung_ohne_schalter_endet_mit_exit_2", - ], - ), -] - - -GRUND = "" - - -def fahre(tests: list[str]) -> bool: - """Exitcode allein ist kein Beleg: Ein Mutant mit Syntaxfehler waere genauso rot. - Deshalb wird die erste Fehlerzeile mitgeschrieben und in der Ausgabe gezeigt.""" - global GRUND - ergebnis = subprocess.run( - [PY, "-m", "pytest", "-q", "-p", "no:cacheprovider", "-x", "--tb=short", *tests], - cwd=MAT, - capture_output=True, - text=True, - stdin=subprocess.DEVNULL, - ) - zeilen = [z.strip() for z in ergebnis.stdout.splitlines() if z.startswith("E ")] - GRUND = " | ".join(zeilen[:2])[:260] - return ergebnis.returncode == 0 - - -def main() -> int: - befunde = 0 - alle = sorted({t for m in MUTANTEN for t in m[4]}) - if not fahre(alle): - print("BEFUND Grundlinie: ohne Mutant sind nicht alle Tests gruen -- der Lauf misst nichts.") - return 2 - print(f"Grundlinie gruen ({len(alle)} Tests).") - for name, datei, alt, neu, tests in MUTANTEN: - pfad = MAT / datei - sicherung = pfad.read_bytes() - text = sicherung.decode("utf-8") - anzahl = text.count(alt) - if anzahl != 1: - print(f"BEFUND Mutant '{name}': Stelle steht {anzahl}-mal in {datei}") - befunde += 1 - continue - try: - pfad.write_bytes(text.replace(alt, neu).encode("utf-8")) - gruen = fahre(tests) - finally: - pfad.write_bytes(sicherung) - if gruen: - print(f"BEFUND Mutant '{name}' bleibt GRUEN: {tests}") - befunde += 1 - else: - print(f"rot {name}\n {GRUND}") - print(f"{befunde} Befund(e).") - return 2 if befunde else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/mutanten_ts.py b/.vault-tools/trading-plattform-pruefung/mutanten_ts.py deleted file mode 100644 index a8811fc..0000000 --- a/.vault-tools/trading-plattform-pruefung/mutanten_ts.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Mutantenlauf Frontend auf der materialisierten Kopie (Windows-Python, vitest je Datei). - -Je Mutant: Datei als Bytes sichern, genau eine Stelle ersetzen, die Testdatei fahren, zurueckspielen. -Erwartet ist ROT; ein gruener Mutant ist ein Befund. Zuerst die Grundlinie. -""" - -import subprocess -import sys -from pathlib import Path - -DASH = Path(sys.argv[1]) / "dashboard" # Windows-Pfad der Kopie mit dem angewendeten Plan - -MUTANTEN = [ - ( - "stufe() gibt den rohen Index zurueck", - "src/components/LaufKampagne.tsx", - " const wert = aktion - (verteilung.length - 1) / 2;", - " const wert = aktion;", - "src/components/LaufKampagne.test.tsx", - ), - ( - "stufe() ohne Pruefung der Verteilung", - "src/components/LaufKampagne.tsx", - " if (!Array.isArray(verteilung) || verteilung.length % 2 === 0) return STRICH;", - " if (!Array.isArray(verteilung)) return STRICH;", - "src/components/LaufKampagne.test.tsx", - ), - ( - "stufe() ohne Pruefung des Bereichs", - "src/components/LaufKampagne.tsx", - " if (aktion < 0 || aktion >= verteilung.length) return STRICH;\n", - "", - "src/components/LaufKampagne.test.tsx", - ), - ( - "Stille Fenster als Ertrag formatiert", - "src/components/LaufKampagne.tsx", - " wert={anteil(abschluss.anteil_fenster_ohne_trade)}", - " wert={prozent(abschluss.anteil_fenster_ohne_trade)}", - "src/components/LaufKampagne.test.tsx", - ), - ( - "close() merkt sich das Ende nicht", - "src/lib/api.ts", - " beendet = true;\n", - "", - "src/lib/api.test.ts", - ), - ( - "close() raeumt den Zeitgeber nicht", - "src/lib/api.ts", - " if (zeitgeber !== null) clearTimeout(zeitgeber);\n", - "", - "src/lib/api.test.ts", - ), - ( - # Der Backslash kommt aus chr(92): Eine Escape-Folge im eigenen Quelltext wertet sonst - # irgendeine Schicht aus -- genau das ist bei der Umsetzung passiert. - "Lizenztext mit doppeltem Backslash (Escape-Folge als Klartext auf der Seite)", - "src/components/Markt.tsx", - "Charts" + chr(92) + "u2122 Copyright (" + chr(92) + "u0441) 2025", - "Charts" + chr(92) * 2 + "u2122 Copyright (" + chr(92) * 2 + "u0441) 2025", - "src/components/Markt.test.tsx", - ), -] - - -def fahre(testdatei: str) -> tuple[bool, str]: - ergebnis = subprocess.run( - f"npx vitest run {testdatei}", - cwd=DASH, - shell=True, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - stdin=subprocess.DEVNULL, - ) - ausgabe = ergebnis.stdout + ergebnis.stderr - rote = [z.strip() for z in ausgabe.splitlines() if z.strip().startswith(("×", "FAIL", "✗")) or " > " in z and "FAIL" in z] - return ergebnis.returncode == 0, "; ".join(rote[:4]) - - -def main() -> int: - befunde = 0 - for testdatei in sorted({m[4] for m in MUTANTEN}): - gruen, _ = fahre(testdatei) - if not gruen: - print(f"BEFUND Grundlinie: {testdatei} ist ohne Mutant nicht gruen") - return 2 - print("Grundlinie gruen.") - for name, datei, alt, neu, testdatei in MUTANTEN: - pfad = DASH / datei - sicherung = pfad.read_bytes() - text = sicherung.decode("utf-8") - anzahl = text.count(alt) - if anzahl != 1: - print(f"BEFUND Mutant '{name}': Stelle steht {anzahl}-mal in {datei}") - befunde += 1 - continue - try: - pfad.write_bytes(text.replace(alt, neu).encode("utf-8")) - gruen, rote = fahre(testdatei) - finally: - pfad.write_bytes(sicherung) - if gruen: - print(f"BEFUND Mutant '{name}' bleibt GRUEN ({testdatei})") - befunde += 1 - else: - print(f"rot {name} [{rote}]") - print(f"{befunde} Befund(e).") - return 2 if befunde else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/neustart_echtlauf.py b/.vault-tools/trading-plattform-pruefung/neustart_echtlauf.py deleted file mode 100644 index 7821b5f..0000000 --- a/.vault-tools/trading-plattform-pruefung/neustart_echtlauf.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Eigener Lauf mit echten Daten: Der Tagesverlust zaehlt nach einem Neustart weiter ab dem Tagesstart. - -Koordinator-Pruefcode, nicht Teil des Repos. Echte IEX-Kerzen eines Tages (JSON aus dem lesenden -Abruf der Abschlusspruefung). Aus den Rohkerzen wird vorgerechnet, bei welcher Kerze die Grenze von -0,2 Prozent gegen den Tagesstart reisst und bei welcher gegen den Neustartkurs; dann laufen zwei -echte PaperSitzungen nacheinander (vor und nach dem Neustart) mit gemeinsamer Tagesstart-Datei, -dazu die Gegenprobe ohne Datei. Kein Netz: Meldungen gehen an einen mitschreibenden Transport. -Aufruf (WSL, aus dem Projektordner): python neustart_echtlauf.py -""" - -import json -import sys -import tempfile -from datetime import datetime -from datetime import timedelta -from pathlib import Path - -sys.path.insert(0, str(Path.cwd())) - -from trading.killswitch.killswitch import lade_killswitch_config -from trading.paper.config import enge_limits -from trading.paper.config import lade_paper_config -from trading.paper.kerzen import FesteKerzenquelle -from trading.paper.kerzen import Kerze -from trading.paper.melder import Melder -from trading.paper.melder import MelderConfig -from trading.paper.probe import ProbeBroker -from trading.paper.sitzung import PaperSitzung -from trading.paper.status import lies_tagesstart -from trading.paper.uhr import ProbeUhr -from trading.registry.registry import Registry -from trading.risk.limits import lade_limits - -STUECK = 20 -ZIEL_KAPITAL = 12_676.20 # so klein, dass 20 Stueck die echte Grenze von 0,2 Prozent erreichen - - -class Protokoll: - def __init__(self): - self.zeilen = [] - - def schreibe(self, eintrag): - self.zeilen.append(dict(eintrag)) - - -class Antwort: - status_code = 200 - - -class Transport: - def __init__(self): - self.aufrufe = [] - - def __call__(self, url, *, data=None, headers=None, timeout=None): - self.aufrufe.append(headers["Title"]) - return Antwort() - - -class Flach: - def predict(self, beobachtung, deterministic=False): - return 2, None - - -def lade(pfad): - roh = json.loads(Path(pfad).read_text(encoding="utf-8")) - return [ - 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 - ] - - -def lauf(ordner, kerzen, *, beginn, position, cash, tagesstart, config, limits, ks_config): - ordner.mkdir(parents=True, exist_ok=True) - quelle = FesteKerzenquelle(kerzen) - uhr = ProbeUhr(beginn) - broker = ProbeBroker(kerzenquelle=quelle, uhr=uhr, instrument_id=config.instrument_id, - startkapital=cash, position=position) - registry = Registry(db_pfad=ordner / "registry.db", artefakt_wurzel=ordner / "artifacts") - registry.setze_killswitch_zustand("angehalten", ausloeser="neustart") - protokoll = Protokoll() - transport = Transport() - sitzung = PaperSitzung( - broker=broker, kerzenquelle=quelle, agent=Flach(), agent_beschreibung={"quelle": "echtlauf"}, - registry=registry, limits=limits, killswitch_config=ks_config, config=config, uhr=uhr, - protokoll=protokoll, protokoll_pfad="echtlauf.jsonl", status_pfad=ordner / "status.json", - modus="probe", soll_abbrechen=lambda: False, - melder=Melder(config=MelderConfig(aktiv=True, ntfy_url="https://ntfy.sh", - thema="echtlauf-geht-nie-ins-netz", zeitlimit_s=3.0, - stille_nach_s=180.0), token="", transport=transport), - tagesstart_pfad=tagesstart, - ) - code = sitzung.laufe() - return code, protokoll.zeilen, transport.aufrufe, broker, registry - - -def kerze_beim_grenzereignis(zeilen, index_nach_zeit): - """Index der juengsten Kerze in dem Takt, in dem die Zeile grenzwert steht, oder None.""" - for stelle, zeile in enumerate(zeilen): - if zeile.get("was") == "grenzwert": - takt = next(z for z in zeilen[stelle:] if z.get("art") == "takt") - return index_nach_zeit[takt["letzte_kerze"]], takt["schritt"] - return None, None - - -def erster_riss(schluesse, *, ab, cash, position, bezug, anteil): - for c in range(ab, len(schluesse)): - if (cash + position * schluesse[c] - bezug) / bezug < -anteil: - return c - return None - - -def main(): - kerzen = lade(sys.argv[1]) - schluesse = [k.close for k in kerzen] - index_nach_zeit = {k.zeit.isoformat(): i for i, k in enumerate(kerzen)} - config = lade_paper_config("config/paper.toml") - limits = enge_limits(lade_limits("config/risk.toml"), config) - ks_config = lade_killswitch_config("config/killswitch.toml") - anteil = limits.max_tagesverlust_anteil - tag = kerzen[0].zeit.date().isoformat() - print(f"{tag}: {len(kerzen)} Kerzen, Schluss Kerze 0 {schluesse[0]:.2f}, " - f"Tief {min(schluesse):.2f}, Hoch {max(schluesse):.2f}, Grenze {anteil}") - - befunde = 0 - for seite, position in (("long", STUECK), ("short", -STUECK)): - cash = ZIEL_KAPITAL - position * schluesse[0] - tagesstart_wert = cash + position * schluesse[0] - riss_tag = erster_riss(schluesse, ab=0, cash=cash, position=position, - bezug=tagesstart_wert, anteil=anteil) - if riss_tag is None or riss_tag < 3: - print(f"[{seite}] der Tag reisst die Grenze gegen den Tagesstart nicht (oder sofort): uebersprungen") - continue - # Neustart so, dass die Sitzung davor die Kerze des Risses nicht mehr sieht: Sie laeuft - # ueber kerzen[:r] und sieht die Kerzen 0 bis r-2. Die Sitzung danach beginnt in der - # Minute der Kerze r und sieht zuerst die Kerze r-1. - r = riss_tag - bezug_neu = cash + position * schluesse[r - 1] - riss_neu = erster_riss(schluesse, ab=r - 1, cash=cash, position=position, - bezug=bezug_neu, anteil=anteil) - print(f"[{seite}] Tagesstart {tagesstart_wert:.2f}; vorgerechnet: Riss gegen den Tagesstart " - f"bei Kerze {riss_tag}, gegen den Neustartkurs ({bezug_neu:.2f}) bei Kerze {riss_neu}") - - with tempfile.TemporaryDirectory(prefix="neustart-echtlauf-") as wurzel: - wurzel = Path(wurzel) - datei = wurzel / "paper" / "tagesstart.json" - gemeinsam = dict(position=position, cash=cash, config=config, limits=limits, - ks_config=ks_config) - - code1, zeilen1, _, _, reg1 = lauf( - wurzel / "vorher", kerzen[:r], beginn=kerzen[0].zeit - timedelta(minutes=1), - tagesstart=datei, **gemeinsam) - riss1, _ = kerze_beim_grenzereignis(zeilen1, index_nach_zeit) - gemerkt = lies_tagesstart(datei, handelstag=tag) - print(f"[{seite}] vor dem Neustart: Exit {code1}, Killswitch " - f"{reg1.killswitch_zustand().zustand}, Riss {riss1}, gemerkt {gemerkt}") - - code2, zeilen2, titel2, broker2, reg2 = lauf( - wurzel / "nachher", kerzen, beginn=kerzen[r].zeit - timedelta(minutes=1), - tagesstart=datei, **gemeinsam) - riss2, schritt2 = kerze_beim_grenzereignis(zeilen2, index_nach_zeit) - takte2 = [z for z in zeilen2 if z.get("art") == "takt"] - # Vor dem Ende des with-Blocks lesen: Danach ist das Wegwerf-Register weg. - zustand2 = reg2.killswitch_zustand().zustand - position2 = sum(p.menge for p in broker2.positionen()) - print(f"[{seite}] nach dem Neustart mit Datei: Startkapital {takte2[0]['startkapital']}, " - f"Riss bei Kerze {riss2} (Schritt {schritt2}), Exit {code2}, Killswitch " - f"{zustand2}, Position {position2}, Meldungen {titel2}") - - code3, zeilen3, _, broker3, reg3 = lauf( - wurzel / "ohne", kerzen, beginn=kerzen[r].zeit - timedelta(minutes=1), - tagesstart=None, **gemeinsam) - riss3, _ = kerze_beim_grenzereignis(zeilen3, index_nach_zeit) - takte3 = [z for z in zeilen3 if z.get("art") == "takt"] - print(f"[{seite}] nach dem Neustart ohne Datei: Startkapital {takte3[0]['startkapital']}, " - f"Riss bei Kerze {riss3}, Exit {code3}, Killswitch {reg3.killswitch_zustand().zustand}") - - ok = ( - riss1 is None and code1 == 4 - and gemerkt is not None and abs(gemerkt - tagesstart_wert) < 1e-9 - and takte2[0]["startkapital"] == gemerkt - and riss2 == riss_tag - and zustand2 == "gesperrt" - and position2 == 0 - and titel2 == ["Paper-Runner wartet", "Grenze verletzt", "Paper-Runner beendet"] - and abs(takte3[0]["startkapital"] - bezug_neu) < 1e-9 - and riss3 == riss_neu - ) - print(f"[{seite}] {'OK' if ok else 'BEFUND'}") - befunde += 0 if ok else 1 - - print(f"{befunde} Befund(e).") - return 2 if befunde else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/ntfy_lesen.py b/.vault-tools/trading-plattform-pruefung/ntfy_lesen.py deleted file mode 100644 index 881bcdb..0000000 --- a/.vault-tools/trading-plattform-pruefung/ntfy_lesen.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Liest zurueck, was bei ntfy unter dem Thema der Maschine ankam, mit maskierten Ziffern. - -Koordinator-Skript, nicht Teil des Repos. Schickt nichts. Das Thema wird nie ausgegeben -- weder -hier noch in einer Fehlermeldung. Aufruf aus dem Projektordner (WSL): -python -B ntfy_lesen.py [--seit 12h | --seit 2026-10-07T20:00] -Exit 0 gelesen, 1 Netzfehler, 3 kein Thema, 4 Weiterleitung oder Fehlerstatus. -""" - -import argparse -import json -import os -import re -import sys -from datetime import datetime -from pathlib import Path -from zoneinfo import ZoneInfo - -sys.path.insert(0, str(Path.cwd())) - -# Ohne Login-Shell fehlt die Variable (LIESMICH, Abschnitt zu den Geheimnissen); der Pfad selbst -# ist kein Geheimnis, sein Inhalt schon. -os.environ.setdefault("TRADING_GEHEIMNISSE", "/home/claude/.config/trading-plattform/secrets.toml") - -import requests - -from trading import geheimnisse -from trading.paper.melder import lade_melder_config -from trading.paper.melder import lies_ntfy_geheimnisse - -BERLIN = ZoneInfo("Europe/Berlin") -# ntfy nimmt fuer since eine Dauer (12h, 30m) oder einen Unix-Zeitstempel (docs.ntfy.sh/subscribe/api). -DAUER = re.compile(r"^\d+[smhd]$") - - -def seit_parameter(wert: str) -> str: - """Eine Dauer bleibt, wie sie ist; eine Ortszeit ohne Zone gilt als Berliner Zeit.""" - if DAUER.match(wert): - return wert - zeit = datetime.fromisoformat(wert) - if zeit.tzinfo is None: - zeit = zeit.replace(tzinfo=BERLIN) - return str(int(zeit.timestamp())) - - -def maskiert(text: object) -> str: - """Jede Ziffernfolge als #, weil eine Meldung einen Betrag tragen kann.""" - return re.sub(r"\d+", "#", str(text)) - - -def main(argv: list[str] | None = None) -> int: - teiler = argparse.ArgumentParser(description="ntfy zuruecklesen, ohne das Thema zu zeigen.") - teiler.add_argument( - "--seit", default="12h", - help="Dauer wie 12h oder Berliner Ortszeit JJJJ-MM-TTTHH:MM; ntfy.sh haelt Meldungen nur Stunden.", - ) - args = teiler.parse_args(argv) - try: - seit = seit_parameter(args.seit) - except ValueError: - print(f"--seit nicht lesbar: {args.seit}") - return 2 - try: - # Beide lesen die Geheimnisdatei; fehlt sie, wirft schon die Konfiguration. - config = lade_melder_config("config/paper.toml", secrets_pfad=None) - thema, token = lies_ntfy_geheimnisse() - except OSError as ausnahme: - print(f"Geheimnisdatei nicht lesbar ({type(ausnahme).__name__}): {geheimnisse.beschreibung()}") - return 3 - if not thema: - # Mit leerem Thema hiesse die Adresse .../json; genau das endete am 2026-10-07 mit HTTP 307. - print(f"Kein Thema in der Geheimnisdatei: {geheimnisse.beschreibung()}") - return 3 - kopf = {"Authorization": f"Bearer {token}"} if token else {} - try: - antwort = requests.get( - f"{config.ntfy_url}/{thema}/json", params={"poll": "1", "since": seit}, - headers=kopf, timeout=10, allow_redirects=False, - ) - except Exception as ausnahme: - # Nur der Typ: Die Ausnahme von requests traegt die Adresse und damit das Thema. - print(f"Ruecklesen gescheitert: {type(ausnahme).__name__}") - return 1 - print(f"Ruecklesen seit {args.seit}: HTTP {antwort.status_code}") - if antwort.status_code >= 300: - # Einer Weiterleitung folgen hiesse, das Token an ein anderes Ziel zu schicken. - print("Kein Ergebnis: Weiterleitung oder Fehlerstatus, nicht gefolgt.") - return 4 - anzahl = 0 - for zeile in antwort.text.splitlines(): - try: - eintrag = json.loads(zeile) - except ValueError: - continue - if eintrag.get("event") != "message": - continue - anzahl += 1 - zeit = datetime.fromtimestamp(eintrag.get("time", 0), BERLIN).strftime("%Y-%m-%d %H:%M:%S") - erste = (str(eintrag.get("message", "")).splitlines() or [""])[0] - # Ohne Angabe fuehrt ntfy die Prioritaet 3 (default) und laesst das Feld weg. - print(f" {zeit} Prioritaet {eintrag.get('priority', 3)} Titel '{maskiert(eintrag.get('title'))}' " - f"erste Zeile '{maskiert(erste)[:70]}'") - print(f"{anzahl} Meldung(en).") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/ntfy_probe_server.py b/.vault-tools/trading-plattform-pruefung/ntfy_probe_server.py deleted file mode 100644 index 8c3b2c6..0000000 --- a/.vault-tools/trading-plattform-pruefung/ntfy_probe_server.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Nimmt ntfy-Meldungen lokal entgegen und schreibt sie mit, statt sie zu verschicken. - -Koordinator-Werkzeug, nicht fuers Repo. Frage (Herkunft: Anforderung Paket D vom 2026-09-18, "eigener Lauf mit -echten Daten ohne echtes Thema"): Welche Meldungen schickt der Paper-Runner oder der Stille-Waechter wirklich -hinaus -- wie viele, mit welchem Titel, welcher Prioritaet, welchem Text? Der Server lauscht nur auf 127.0.0.1, -der Melder erlaubt http genau fuer diese Adresse. -Mitgeschrieben wird je Anfrage eine JSONL-Zeile: Zeit, Methode, Pfad, Title, Priority, Tags, Textkoerper und vom -Authorization-Kopf nur, ob er da ist, sein Schema und seine Laenge -- nie der Wert. -Was er nicht sieht: ob ntfy.sh dieselbe Anfrage annaehme (Themenregeln, Groessengrenzen, Ratenbegrenzung). -Regressionsnachweis: --status 403 laesst jede Anfrage scheitern; der Melder muss dann einen Fehlschlag melden -und der Runner trotzdem mit demselben Exit-Code enden. - -Mit --status-folge 403,403,200 antwortet er der Reihe nach mit diesen Codes und bleibt beim letzten; so zeigt -sich, ob der Stille-Waechter eine gescheiterte Meldung wiederholt, bis sie draussen ist. Die Mitschrift nennt -je Zeile den Status, mit dem geantwortet wurde. - -Aufruf: python ntfy_probe_server.py [--status 200] [--status-folge 403,200] [--verzoegerung 0.0] -Beenden mit Strg+C oder SIGTERM. -""" - -import json -import sys -import threading -import time -from datetime import datetime, timezone -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - - -def baue_handler(mitschrift: str, folge: list[int], verzoegerung: float): - sperre = threading.Lock() - - def naechster_status() -> int: - with sperre: - return folge.pop(0) if len(folge) > 1 else folge[0] - - class Handler(BaseHTTPRequestHandler): - def _nimm(self) -> None: - laenge = int(self.headers.get("Content-Length") or 0) - koerper = self.rfile.read(laenge) if laenge else b"" - auth = self.headers.get("Authorization") - status = naechster_status() - zeile = { - "zeit": datetime.now(timezone.utc).isoformat(), - "methode": self.command, - "pfad": self.path, - "title": self.headers.get("Title"), - "priority": self.headers.get("Priority"), - "tags": self.headers.get("Tags"), - "text": koerper.decode("utf-8", errors="replace"), - "authorization_da": auth is not None, - "authorization_schema": auth.split(" ", 1)[0] if auth else None, - "authorization_laenge": len(auth) if auth else 0, - "status": status, - } - with open(mitschrift, "a", encoding="utf-8") as datei: - datei.write(json.dumps(zeile, ensure_ascii=False) + "\n") - if verzoegerung: - time.sleep(verzoegerung) - antwort = json.dumps({"id": "probe", "event": "message"}).encode("utf-8") - self.send_response(status) - if 300 <= status < 400: - # Eine echte Weiterleitung auf denselben Server: Folgt der Melder ihr, steht eine zweite Zeile in der Mitschrift. - self.send_header("Location", f"http://127.0.0.1:{self.server.server_address[1]}/weitergeleitet") - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(antwort))) - self.end_headers() - self.wfile.write(antwort) - - do_POST = _nimm - do_PUT = _nimm - - def log_message(self, *_args) -> None: # die Mitschrift ist das Protokoll - return - - return Handler - - -def main() -> int: - argumente = sys.argv[1:] - if len(argumente) < 2: - print(__doc__) - return 3 - port, mitschrift = int(argumente[0]), argumente[1] - status = int(argumente[argumente.index("--status") + 1]) if "--status" in argumente else 200 - folge = [status] - if "--status-folge" in argumente: - folge = [int(teil) for teil in argumente[argumente.index("--status-folge") + 1].split(",")] - verzoegerung = float(argumente[argumente.index("--verzoegerung") + 1]) if "--verzoegerung" in argumente else 0.0 - server = ThreadingHTTPServer(("127.0.0.1", port), baue_handler(mitschrift, folge, verzoegerung)) - print(f"ntfy-Probe lauscht auf 127.0.0.1:{port}, Statusfolge {folge}, Mitschrift {mitschrift}", flush=True) - try: - server.serve_forever() - except KeyboardInterrupt: - pass - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/pause_echtlauf.py b/.vault-tools/trading-plattform-pruefung/pause_echtlauf.py deleted file mode 100644 index aaea19e..0000000 --- a/.vault-tools/trading-plattform-pruefung/pause_echtlauf.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Eigener Lauf mit echten Daten: Tagesverlust-Schutz in der Pause auf den IEX-Kerzen eines echten Tages. - -Koordinator-Pruefcode, nicht Teil des Repos. Er rechnet aus den Rohkerzen vor, in welchem Takt die -Grenze reissen muss, und haelt das gegen das Protokoll der echten PaperSitzung. Der einzige -Netzzugriff holt lesend die Kerzen; Meldungen gehen an einen mitschreibenden Transport, nie ins Netz. -Aufruf (aus dem Projektordner, in WSL): python pause_echtlauf.py JJJJ-MM-TT -""" - -import sys -import tempfile -from datetime import datetime -from datetime import timedelta -from pathlib import Path - -sys.path.insert(0, str(Path.cwd())) - -from trading.killswitch.killswitch import lade_killswitch_config -from trading.paper.config import enge_limits -from trading.paper.config import lade_paper_config -from trading.paper.kerzen import HANDELSZONE -from trading.paper.kerzen import AlpacaKerzenquelle -from trading.paper.kerzen import FesteKerzenquelle -from trading.paper.kerzen import handelsende -from trading.paper.melder import Melder -from trading.paper.melder import MelderConfig -from trading.paper.probe import ProbeBroker -from trading.paper.sitzung import PaperSitzung -from trading.paper.uhr import ProbeUhr -from trading.registry.registry import Registry -from trading.risk.limits import lade_limits -from dataclasses import replace - - -class Protokoll: - def __init__(self): - self.zeilen = [] - - def schreibe(self, eintrag): - self.zeilen.append(dict(eintrag)) - - -class Antwort: - status_code = 200 - - -class Transport: - def __init__(self): - self.aufrufe = [] - - def __call__(self, url, *, data=None, headers=None, timeout=None): - self.aufrufe.append((headers["Title"], headers["Priority"], data.decode("utf-8"))) - return Antwort() - - -class Flach: - def predict(self, beobachtung, deterministic=False): - return 2, None - - -def lauf(kerzen, *, config, limits, killswitch_config, zustand, position): - ordner = Path(tempfile.mkdtemp(prefix="pause-echtlauf-")) - quelle = FesteKerzenquelle(kerzen) - uhr = ProbeUhr(kerzen[0].zeit - timedelta(minutes=1)) - broker = ProbeBroker( - kerzenquelle=quelle, uhr=uhr, instrument_id=config.instrument_id, position=position - ) - registry = Registry(db_pfad=ordner / "registry.db", artefakt_wurzel=ordner / "artifacts") - registry.setze_killswitch_zustand(zustand, ausloeser="echtlauf") - protokoll = Protokoll() - transport = Transport() - melder = Melder( - config=MelderConfig( - aktiv=True, - ntfy_url="https://ntfy.sh", - thema="echtlauf-geht-nie-ins-netz", - zeitlimit_s=3.0, - stille_nach_s=180.0, - ), - token="", - transport=transport, - ) - sitzung = PaperSitzung( - broker=broker, - kerzenquelle=quelle, - agent=Flach(), - agent_beschreibung={"quelle": "echtlauf"}, - registry=registry, - limits=limits, - killswitch_config=killswitch_config, - config=config, - uhr=uhr, - protokoll=protokoll, - protokoll_pfad="echtlauf.jsonl", - status_pfad=ordner / "status.json", - modus="probe", - soll_abbrechen=lambda: False, - melder=melder, - ) - code = sitzung.laufe() - return code, protokoll.zeilen, transport.aufrufe, broker, registry - - -def main(): - datum = sys.argv[1] - mitternacht = datetime.strptime(datum, "%Y-%m-%d").replace(tzinfo=HANDELSZONE) - config = lade_paper_config("config/paper.toml") - killswitch_config = lade_killswitch_config("config/killswitch.toml") - quelle = AlpacaKerzenquelle.from_secrets(None, feed=config.feed, timeout=10.0) - kerzen = list(quelle.kerzen_des_tages(config.instrument_id, handelsende(mitternacht))) - print(f"{datum}: {len(kerzen)} IEX-Kerzen in {config.instrument_id}") - schluesse = [k.close for k in kerzen] - print(f"Schluss erste Kerze {schluesse[0]:.2f}, Tief {min(schluesse):.2f}, Hoch {max(schluesse):.2f}") - - stueck = 20 - cash = 100_000.0 - befunde = 0 - for seite, position in (("long", stueck), ("short", -stueck)): - # Eigene Rechnung aus den Rohkerzen: Takt k (schritt k) sieht die Kerze k-1 als juengste - # abgeschlossene. Startkapital ist der Kontowert im ersten Takt. - start = cash + position * schluesse[0] - schlechtester = min(cash + position * s for s in schluesse) - start - if schlechtester >= 0: - print(f"[{seite}] der Tag bringt dieser Seite keinen Verlust gegen den ersten Takt; uebersprungen") - continue - # Die Grenze liegt bei der Haelfte des groessten Verlusts dieses Tages: Der Tag reisst sie - # wirklich, und nicht im ersten Takt. - anteil = abs(schlechtester) / start / 2.0 - erwartet = None - for k, s in enumerate(schluesse, start=1): - if (cash + position * s - start) / start < -anteil: - erwartet = k - break - limits = replace(enge_limits(lade_limits("config/risk.toml"), config), max_tagesverlust_anteil=anteil) - code, zeilen, meldungen, broker, registry = lauf( - kerzen, config=config, limits=limits, killswitch_config=killswitch_config, - zustand="angehalten", position=position, - ) - takte = [z for z in zeilen if z.get("art") == "takt"] - grenz = [z for z in zeilen if z.get("was") == "grenzwert"] - # Der Takt, in dem die Zeile grenzwert steht: die Taktzeile danach traegt seinen Schritt. - stelle = zeilen.index(grenz[0]) if grenz else None - schritt = next((z["schritt"] for z in zeilen[stelle:] if z.get("art") == "takt"), None) if grenz else None - titel = [m[0] for m in meldungen] - print(f"[{seite}] Startkapital {start:.2f}, Grenze {anteil:.6f} (= {anteil * start:.2f} USD), " - f"erwarteter Takt {erwartet}, gemessen {schritt}") - print(f"[{seite}] Zustand {registry.killswitch_zustand().zustand}, Position danach " - f"{sum(p.menge for p in broker.positionen())}, Exit {code}, Meldungen {titel}") - print(f"[{seite}] schliesse_alle: {broker.aufrufe.count('schliesse_alle')}, Takte {len(takte)}, " - f"grenzwert-Zeilen {len(grenz)}") - ok = ( - schritt == erwartet - and registry.killswitch_zustand().zustand == "gesperrt" - and broker.aufrufe.count("schliesse_alle") == 1 - and len(grenz) == 1 - and titel == ["Paper-Runner wartet", "Grenze verletzt", "Paper-Runner beendet"] - and code == 0 - ) - print(f"[{seite}] {'OK' if ok else 'BEFUND'}") - befunde += 0 if ok else 1 - - # Gegenprobe: dieselben Kerzen, angehalten, ohne Position -- genau eine Kontoabfrage, nichts geschlossen. - limits = enge_limits(lade_limits("config/risk.toml"), config) - code, zeilen, meldungen, broker, registry = lauf( - kerzen, config=config, limits=limits, killswitch_config=killswitch_config, - zustand="angehalten", position=0, - ) - titel = [m[0] for m in meldungen] - ok = ( - broker.aufrufe.count("konto") == 1 - and "schliesse_alle" not in broker.aufrufe - and titel == ["Paper-Runner wartet", "Paper-Runner beendet"] - and code == 0 - ) - print(f"[ohne Position] konto {broker.aufrufe.count('konto')}, Meldungen {titel}, Exit {code}: " - f"{'OK' if ok else 'BEFUND'}") - befunde += 0 if ok else 1 - - # Gegenprobe 2: aktiv mit dem echten Haltemuster aendert sich nicht -- das prueft die Probe des Skripts. - print(f"{befunde} Befund(e).") - return 2 if befunde else 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/probe_neu_einfrieren.py b/.vault-tools/trading-plattform-pruefung/probe_neu_einfrieren.py deleted file mode 100644 index a920e56..0000000 --- a/.vault-tools/trading-plattform-pruefung/probe_neu_einfrieren.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Friert die T0/T1-Probe der Tagesmessung nur dann neu ein, wenn der Diff genau der vorhergesagte ist. - -Koordinator-Werkzeug, nicht fuers Repo. Frage (Herkunft: Plan Paket B vom 2026-09-18, Task 154, und die Messung -der Plankritik): Aendert der Umbau der gepoolten Prognose an der eingefrorenen Probe -tests/fixtures/tage_t0_t1_referenz.json genau das, was er aendern darf -- und sonst nichts? -Erlaubt ist: - - in jeder Zeile der Art zelle_pool der neue Schluessel fenster_ausgelassen_pool, - - in der Zeile zelle_pool mit merkmale T1 geaenderte Werte in den Feldern aus ERLAUBT_T1_POOL. -Alles andere ist ein Befund: jede geaenderte Zahl in einer Zeile zelle_instrument, in einer T0-Zeile oder in -einem Feld ausserhalb der Liste, jeder fehlende Schluessel, jede andere Zeilenzahl. -Die neuen Zeilen entstehen ueber den Helfer des Tests (_t0_t1_protokollzeilen), also mit dem Code des Baums, -aus dem das Werkzeug gestartet wird. Geschrieben wird nur mit --schreiben und nur ohne Befund, im Format des -Rezepts aus Teil 9 (indent 2, ensure_ascii aus, Zeilenende LF -- deshalb in WSL starten). -Was es nicht sieht: ob die neuen Zahlen richtig sind. Es belegt nur, dass sich nichts ausserhalb des -vorhergesagten Bereichs bewegt hat. -Regressionsnachweis: --sabotage verschiebt in den neuen Zeilen eine Zahl einer T0-Zeile um ein ULP; das muss -einen Befund geben, und --schreiben darf dann nichts schreiben. - -Aufruf (WSL, aus der Projektwurzel): python probe_neu_einfrieren.py [--schreiben] [--sabotage] -Exit 0 ohne Befund, 2 mit Befund, 3 wenn eine Voraussetzung fehlt. -""" - -import json -import math -import sys -import tempfile -from pathlib import Path - -FIXTURE = Path("tests/fixtures/tage_t0_t1_referenz.json") -NEUER_SCHLUESSEL = "fenster_ausgelassen_pool" -# Gemessen von der Plankritik am 2026-09-18 (Feld-fuer-Feld-Vergleich auf einer Kopie mit angewendetem Plan). -ERLAUBT_T1_POOL = ( - "K3", - "anzahl_fenster", - "ic_haelfte_1", - "ic_haelfte_2", - "ic_pool", - "n_pool", - "perioden", - "sharpe_", - "t_pool_naiv", - "treffer_pool", -) - - -def flach(knoten, praefix: str = "") -> dict: - werte: dict = {} - if isinstance(knoten, dict): - for name, inhalt in knoten.items(): - werte.update(flach(inhalt, f"{praefix}.{name}")) - elif isinstance(knoten, list): - for nummer, inhalt in enumerate(knoten): - werte.update(flach(inhalt, f"{praefix}[{nummer}]")) - else: - werte[praefix] = knoten - return werte - - -def gleich(a, b) -> bool: - if isinstance(a, float) and isinstance(b, float): - return a == b or (math.isnan(a) and math.isnan(b)) - return type(a) is type(b) and a == b - - -def erlaubt(zeile: dict, pfad: str) -> bool: - if zeile.get("art") != "zelle_pool" or zeile.get("merkmale") != "T1": - return False - feld = pfad.lstrip(".").split(".")[0].split("[")[0] - return any(feld == name or (name.endswith("_") and feld.startswith(name)) for name in ERLAUBT_T1_POOL) - - -def main() -> int: - argumente = sys.argv[1:] - if not FIXTURE.is_file() or not Path("tests/test_training_signal_tage.py").is_file(): - print("VORAUSSETZUNG FEHLT: aus der Projektwurzel starten (tests/fixtures und tests/ muessen da sein)") - return 3 - sys.path.insert(0, "tests") - sys.path.insert(0, ".") - from test_training_signal_tage import _t0_t1_protokollzeilen - - alt = json.loads(FIXTURE.read_text(encoding="utf-8")) - with tempfile.TemporaryDirectory() as ordner: - neu = _t0_t1_protokollzeilen(Path(ordner)) - # Dieselbe Reise wie die Fixture: einmal durch JSON, damit nan/inf und Typen gleich ankommen. - neu = json.loads(json.dumps(neu, ensure_ascii=False)) - - if "--sabotage" in argumente: - for zeile in neu: - if zeile.get("merkmale") == "T0": - zahlen = [k for k, v in zeile.items() if isinstance(v, float) and math.isfinite(v) and v != 0.0] - if zahlen: - zeile[zahlen[0]] = math.nextafter(zeile[zahlen[0]], math.inf) - print(f"sabotage: {zahlen[0]} in einer T0-Zeile der Art {zeile.get('art')} um ein ULP verschoben") - break - - befunde: list[str] = [] - if len(alt) != len(neu): - befunde.append(f"Zeilenzahl: alt {len(alt)}, neu {len(neu)}") - geaendert: dict[str, list[str]] = {} - dazu: dict[str, list[str]] = {} - for nummer, (a, n) in enumerate(zip(alt, neu), start=1): - kennung = f"Zeile {nummer} ({a.get('art')}/{a.get('symbol', '-')}/{a.get('merkmale')})" - if (a.get("art"), a.get("symbol"), a.get("merkmale")) != (n.get("art"), n.get("symbol"), n.get("merkmale")): - befunde.append(f"{kennung}: die neue Zeile ist eine andere ({n.get('art')}/{n.get('symbol', '-')}/{n.get('merkmale')})") - continue - fa, fn = flach(a), flach(n) - for pfad in sorted(fa.keys() - fn.keys()): - befunde.append(f"{kennung}: NUR ALT {pfad}") - for pfad in sorted(fn.keys() - fa.keys()): - if pfad == f".{NEUER_SCHLUESSEL}" and a.get("art") == "zelle_pool": - dazu.setdefault(kennung, []).append(f"{pfad} = {fn[pfad]!r}") - else: - befunde.append(f"{kennung}: NUR NEU {pfad}") - for pfad in sorted(fa.keys() & fn.keys()): - if gleich(fa[pfad], fn[pfad]): - continue - if erlaubt(a, pfad): - geaendert.setdefault(kennung, []).append(pfad) - else: - befunde.append(f"{kennung}: ABWEICHUNG ausserhalb des Erlaubten {pfad}: alt {fa[pfad]!r}, neu {fn[pfad]!r}") - pool_zeilen = [z for z in alt if z.get("art") == "zelle_pool"] - if len(dazu) != len(pool_zeilen): - befunde.append(f"der neue Schluessel steht in {len(dazu)} von {len(pool_zeilen)} Zeilen der Art zelle_pool") - - for kennung, pfade in dazu.items(): - print(f"NEU {kennung}: {', '.join(pfade)}") - for kennung, pfade in geaendert.items(): - print(f"ERLAUBT GEAENDERT {kennung}: {len(pfade)} Werte: {', '.join(pfade)}") - for befund in befunde[:30]: - print("BEFUND:", befund) - print(f"{len(befunde)} Befund(e).") - if befunde: - return 2 - if "--schreiben" in argumente: - with open(FIXTURE, "w", encoding="utf-8", newline="\n") as datei: - datei.write(json.dumps(neu, ensure_ascii=False, indent=2) + "\n") - print(f"geschrieben: {FIXTURE}") - return 0 - - -if __name__ == "__main__": - try: - sys.exit(main()) - except Exception as ausnahme: # noqa: BLE001 - ein Absturz des Werkzeugs ist selbst ein Befund - print(f"BEFUND: das Werkzeug ist abgebrochen: {ausnahme!r}") - sys.exit(2) diff --git a/.vault-tools/trading-plattform-pruefung/protokoll_vergleich.py b/.vault-tools/trading-plattform-pruefung/protokoll_vergleich.py deleted file mode 100644 index 69e7c0e..0000000 --- a/.vault-tools/trading-plattform-pruefung/protokoll_vergleich.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Vergleicht zwei JSONL-Protokolle der Messwerkzeuge Wert fuer Wert (Bitgleichheit nach einem Umbau). - -Koordinator-Werkzeug, nicht fuers Repo. Frage (Herkunft: Projektvorgabe, Anforderung Paket B vom -2026-09-18, "Die eine Regel ueber allem"): Liefert der umgebaute Code auf denselben echten Daten dieselben -Zahlen wie das Referenzprotokoll -- jede Gleitkommazahl exakt gleich, nan gleich nan? -Es unterscheidet drei Dinge und vermischt sie nicht: - ABWEICHUNG ein Blattwert unter demselben Pfad ist anders -> Befund (Exit 2) - NUR NEU ein Pfad steht nur im neuen Protokoll (neuer Schluessel) -> gemeldet, kein Befund, ausser mit --streng - NUR ALT ein Pfad steht nur im alten Protokoll -> Befund (Exit 2) -Zeilen werden in ihrer Reihenfolge gepaart; verschiedene Zeilenzahlen oder Zeilenarten sind ein Befund. -Uebergangen werden die Schluessel aus --ohne (Vorgabe: zeit, dauer_s, protokoll, name), weil sie vom Lauf -abhaengen und nicht von der Rechnung. -Was es nicht sieht: ob das Referenzprotokoll selbst stimmt, und Zeilen, die in beiden Protokollen fehlen. -Regressionsnachweis: --sabotage veraendert im neuen Protokoll eine Zahl in der letzten Stelle und muss rot enden. - -Aufruf: python protokoll_vergleich.py [--ohne k1 k2 ...] [--streng] [--sabotage] -Exit 0 ohne Befund, 2 mit Befund, 3 wenn eine Datei fehlt oder leer ist. -""" - -import json -import math -import sys -from pathlib import Path - - -def lies(pfad: Path) -> list[dict]: - zeilen = [] - with open(pfad, encoding="utf-8") as datei: - for zeile in datei: - if zeile.strip(): - zeilen.append(json.loads(zeile, parse_constant=float)) - return zeilen - - -def flach(knoten, ohne: set[str], praefix: str = "") -> dict: - werte: dict = {} - if isinstance(knoten, dict): - for name, inhalt in knoten.items(): - if name in ohne: - continue - werte.update(flach(inhalt, ohne, f"{praefix}.{name}")) - elif isinstance(knoten, list): - for nummer, inhalt in enumerate(knoten): - werte.update(flach(inhalt, ohne, f"{praefix}[{nummer}]")) - else: - werte[praefix] = knoten - return werte - - -def gleich(a, b) -> bool: - if isinstance(a, float) and isinstance(b, float): - return a == b or (math.isnan(a) and math.isnan(b)) - return type(a) is type(b) and a == b - - -def main() -> int: - argumente = sys.argv[1:] - streng = "--streng" in argumente - sabotage = "--sabotage" in argumente - ohne = {"zeit", "dauer_s", "protokoll", "name"} - if "--ohne" in argumente: - stelle = argumente.index("--ohne") - ohne = set() - for wort in argumente[stelle + 1 :]: - if wort.startswith("--"): - break - ohne.add(wort) - pfade = [Path(a) for a in argumente if not a.startswith("--") and a not in ohne] - if len(pfade) != 2 or not all(p.is_file() for p in pfade): - print(__doc__) - return 3 - alt, neu = lies(pfade[0]), lies(pfade[1]) - if not alt or not neu: - print("VORAUSSETZUNG FEHLT: ein Protokoll ist leer") - return 3 - if sabotage: - for zeile in reversed(neu): - zahlen = [k for k, v in zeile.items() if isinstance(v, float) and math.isfinite(v) and v != 0.0] - if zahlen: - zeile[zahlen[0]] = math.nextafter(zeile[zahlen[0]], math.inf) - print(f"sabotage: {zahlen[0]} in einer Zeile der Art {zeile.get('art')} um ein ULP verschoben") - break - - befunde: list[str] = [] - if len(alt) != len(neu): - befunde.append(f"Zeilenzahl: alt {len(alt)}, neu {len(neu)}") - verglichen = 0 - nur_neu: dict[str, int] = {} - for nummer, (a, n) in enumerate(zip(alt, neu), start=1): - if a.get("art") != n.get("art"): - befunde.append(f"Zeile {nummer}: Art alt {a.get('art')!r}, neu {n.get('art')!r}") - continue - fa, fn = flach(a, ohne), flach(n, ohne) - for pfad in fa.keys() - fn.keys(): - befunde.append(f"Zeile {nummer} ({a.get('art')}): NUR ALT {pfad}") - for pfad in fn.keys() - fa.keys(): - kurz = pfad.split("[")[0] - nur_neu[kurz] = nur_neu.get(kurz, 0) + 1 - if streng: - befunde.append(f"Zeile {nummer} ({a.get('art')}): NUR NEU {pfad}") - for pfad in fa.keys() & fn.keys(): - verglichen += 1 - if not gleich(fa[pfad], fn[pfad]): - befunde.append(f"Zeile {nummer} ({a.get('art')}): ABWEICHUNG {pfad}: alt {fa[pfad]!r}, neu {fn[pfad]!r}") - - print(f"{min(len(alt), len(neu))} Zeilen gepaart, {verglichen} Werte verglichen, uebergangene Schluessel: {sorted(ohne)}") - for pfad, zahl in sorted(nur_neu.items()): - print(f"NUR NEU: {pfad} in {zahl} Zeile(n)") - for befund in befunde[:25]: - print("BEFUND:", befund) - if len(befunde) > 25: - print(f"... und {len(befunde) - 25} weitere") - print(f"{len(befunde)} Befund(e).") - return 2 if befunde else 0 - - -if __name__ == "__main__": - try: - sys.exit(main()) - except Exception as ausnahme: # noqa: BLE001 - ein Absturz des Werkzeugs ist selbst ein Befund - print(f"BEFUND: das Werkzeug ist abgebrochen: {ausnahme!r}") - sys.exit(2) diff --git a/.vault-tools/trading-plattform-pruefung/pruef_beobachtung_echt.py b/.vault-tools/trading-plattform-pruefung/pruef_beobachtung_echt.py deleted file mode 100644 index 44b2c01..0000000 --- a/.vault-tools/trading-plattform-pruefung/pruef_beobachtung_echt.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Beobachtung des Paper-Runners gegen die der NautilusEnv — auf ECHTEN IEX-Kerzen eines Tages. - -Koordinator-Pruefskript zu Teil 10, nicht fuers Repo; ein Fangnetz, kein Nachweis. - -Frage (Herkunft: Projektvorgabe, Spec Teil 10, "Abweichungen vom Training"): Liefert der Weg des -Runners (REST-Antwort -> AlpacaKerzenquelle -> Kerze -> baue_beobachtung) Wert fuer Wert dieselbe -Beobachtung wie der Weg des Trainings (dieselbe REST-Antwort -> alpaca_json_zu_bars -> Bar -> -NautilusEnv._beobachtung)? Der Test im Repo baut seine Kerzen aus den Bars selbst und sieht deshalb -keine Abweichung zwischen den zwei Umwandlungen; dieser Lauf nimmt zwei getrennte Wege von derselben -Antwort. -Zweite Frage (seit 2026-09-18, Prueferbefund zu Teil 10: der erste Teil vergleicht nur position = 0, -kontowert = startkapital, schritte_seit_trade = 0 -- drei der vier Schlusswerte sind dort auf beiden -Seiten Konstanten): Stimmen die vier Schlusswerte auch bei offener Long- und Short-Position, bei einem -Kontowert ungleich dem Startkapital und bei schritte_seit_trade groesser null? Dafuer faehrt die -Umgebung eine feste Aktionsfolge ueber alle fuenf Stufen, und nach jedem Schritt baut der Weg des -Runners seine Beobachtung aus DEMSELBEN Zustand (Position, Kontowert und Zaehler aus der Umgebung -gelesen). Geprueft wird damit die Rechnung des Runners, nicht die Herkunft seines Zustands. -Was es nicht sieht: ob Position und Kontowert im Betrieb richtig vom Broker kommen (Vorzeichen bei -Shorts: erst am Paper-Konto messbar), den Zaehler schritte_seit_trade des Runners selbst (er zaehlt nur -in Entscheidungstakten, todo Teil 10), SIP gegen IEX (gemessen in der Vorabmessung), andere -Instrumente und Tage. -Regressionsnachweis: --sabotage verschiebt einen Schlusskurs um einen Cent und muss mit Exit 2 enden. ---mutant-vorzeichen tauscht die Funktion des Runners gegen eine, die den Betrag der Position nimmt -(ein Vorzeichenfehler im Ziel); der erste Teil bleibt dabei gruen, der zweite muss rot sein. - -Aufruf in WSL aus dem Projektordner (dort liegt config/secrets.toml; der Schluessel wird nie ausgegeben): - python pruef_beobachtung_echt.py 2026-09-17 [--sabotage] [--mutant-vorzeichen] -""" - -import dataclasses -import sys -import tomllib -from datetime import datetime -from pathlib import Path - -import numpy as np -import requests - -from nautilus_trader.model.data import BarType -from nautilus_trader.test_kit.providers import TestInstrumentProvider - -from trading import geheimnisse -from trading.data.loaders import alpaca_json_zu_bars -from trading.data.loaders import nur_regulaere_handelszeit -from trading.paper.beobachtung import baue_beobachtung -from trading.paper.kerzen import HANDELSZONE -from trading.paper.kerzen import AlpacaKerzenquelle -from trading.paper.kerzen import handelsbeginn -from trading.paper.kerzen import handelsende -from trading.rl.env import EnvConfig -from trading.rl.env import NautilusEnv -from trading.sim.venues import lade_venue_config - -FENSTER = 20 -EPISODE = 120 -PERIODEN = (5, 20) -BASIS = 100 -MAX_STUFE = 2 -BAR_TYPE = "AAPL.XNAS-1-MINUTE-LAST-EXTERNAL" - - -def main() -> int: - if len(sys.argv) < 2: - print(__doc__) - return 3 - tag = datetime.strptime(sys.argv[1], "%Y-%m-%d").replace(tzinfo=HANDELSZONE) - sabotage = "--sabotage" in sys.argv[2:] - mutant = "--mutant-vorzeichen" in sys.argv[2:] - ende = handelsende(tag) - - # Weg des Runners. - kerzen = AlpacaKerzenquelle.from_secrets(None, feed="iex").kerzen_des_tages( - "AAPL.XNAS", ende - ) - print("Kerzen ueber die Kerzenquelle:", len(kerzen)) - if len(kerzen) < FENSTER + EPISODE: - print("VORAUSSETZUNG FEHLT: zu wenige Kerzen fuer eine Episode") - return 3 - - # Weg des Trainings: dieselbe Anfrage noch einmal roh, dann die Umwandlung des Katalogs. - geheim = tomllib.loads(geheimnisse.pfad().read_text(encoding="utf-8"))["alpaca"] - kopf = {"APCA-API-KEY-ID": geheim["key_id"], "APCA-API-SECRET-KEY": geheim["secret_key"]} - parameter = { - "symbols": "AAPL", - "timeframe": "1Min", - "start": handelsbeginn(ende).isoformat().replace("+00:00", "Z"), - "end": ende.isoformat().replace("+00:00", "Z"), - "feed": "iex", - "adjustment": "raw", - "limit": 10000, - } - antwort = requests.get( - "https://data.alpaca.markets/v2/stocks/bars", headers=kopf, params=parameter, timeout=30 - ) - antwort.raise_for_status() - nutzlast = antwort.json() - if nutzlast.get("next_page_token"): - print("VORAUSSETZUNG FEHLT: die Antwort hat eine Folgeseite; der Vergleich waere unvollstaendig") - return 3 - instrument = TestInstrumentProvider.equity(symbol="AAPL", venue="XNAS") - bars = nur_regulaere_handelszeit( - alpaca_json_zu_bars( - nutzlast, - symbol="AAPL", - bar_type=BarType.from_str(BAR_TYPE), - price_precision=instrument.price_precision, - size_precision=instrument.size_precision, - ) - ) - print("Kerzen ueber den Katalogweg: ", len(bars)) - if len(bars) != len(kerzen): - print("BEFUND: die zwei Wege liefern verschieden viele Kerzen") - return 2 - - halbe_cent = sum( - 1 - for eintrag in nutzlast["bars"]["AAPL"] - for feld in ("o", "h", "l", "c") - if round(float(eintrag[feld]) * 100) != float(eintrag[feld]) * 100 - ) - print("Rohkurse mit mehr als zwei Nachkommastellen:", halbe_cent) - - if sabotage: - kerzen = list(kerzen) - kerzen[57] = dataclasses.replace(kerzen[57], close=round(kerzen[57].close + 0.01, 2)) - - venue = lade_venue_config("config/venues/us_equity.toml") - env = NautilusEnv( - venue_config=venue, - instrument=instrument, - bars=bars, - config=EnvConfig( - bar_type=BAR_TYPE, - fenster=FENSTER, - basis_menge=BASIS, - max_stufe=MAX_STUFE, - episoden_laenge=EPISODE, - indikator_perioden=PERIODEN, - ), - ) - startkapital = venue.starting_balance.as_double() - - verglichen = 0 - ungleich = 0 - groesste = 0.0 - for start in range(0, len(bars) - FENSTER - EPISODE + 1, 5): - soll, _ = env.reset(options={"start_index": start}) - ist = baue_beobachtung( - kerzen[start : start + FENSTER], - position=0, - hoechste_position=MAX_STUFE * BASIS, - kontowert=startkapital, - startkapital=startkapital, - schritte_seit_trade=0, - episoden_laenge=EPISODE, - indikator_perioden=PERIODEN, - ) - verglichen += 1 - if soll.shape != ist.shape or not np.array_equal(soll, ist): - ungleich += 1 - abstand = float(np.max(np.abs(soll.astype(np.float64) - ist.astype(np.float64)))) - groesste = max(groesste, abstand) - if ungleich <= 3: - stellen = np.flatnonzero(soll != ist)[:5] - print(f" ungleich ab Kerze {start}: Stellen {stellen.tolist()}, groesster Abstand {abstand:.3e}") - print(f"Teil 1: {verglichen} Beobachtungen verglichen, {ungleich} ungleich, groesster Abstand {groesste:.3e}") - - # Teil 2: Zustand ungleich null. Aktion a heisst Stufe a - MAX_STUFE; die Folge geht ueber - # +2, +1, flach, -2, -1 und haelt dazwischen, damit schritte_seit_trade waechst. - folge = [4, 4, 4, 3, 3, 2, 0, 0, 0, 1, 1, 2] - runner = baue_beobachtung - if mutant: - def runner(fenster_kerzen, *, position, **rest): # noqa: E306 - Mutant: Betrag statt Vorzeichen - return baue_beobachtung(fenster_kerzen, position=abs(position), **rest) - - zustand = {"long": 0, "short": 0, "flach": 0, "kontowert_ungleich": 0, "zaehler_groesser_null": 0} - verglichen2 = 0 - ungleich2 = 0 - groesste2 = 0.0 - letzter_start = len(bars) - FENSTER - EPISODE - for start in range(0, letzter_start + 1, max(1, letzter_start // 9)): - env.reset(options={"start_index": start}) - for k, aktion in enumerate(folge, 1): - soll, _, beendet, abgeschnitten, _ = env.step(aktion) - position = env._strategie.netto_position() - ist = runner( - kerzen[start + k : start + k + FENSTER], - position=position, - hoechste_position=MAX_STUFE * BASIS, - kontowert=env._kontowert, - startkapital=startkapital, - schritte_seit_trade=env._schritte_seit_trade, - episoden_laenge=EPISODE, - indikator_perioden=PERIODEN, - ) - zustand["long" if position > 0 else "short" if position < 0 else "flach"] += 1 - zustand["kontowert_ungleich"] += env._kontowert != startkapital - zustand["zaehler_groesser_null"] += env._schritte_seit_trade > 0 - verglichen2 += 1 - if soll.shape != ist.shape or not np.array_equal(soll, ist): - ungleich2 += 1 - abstand = float(np.max(np.abs(soll.astype(np.float64) - ist.astype(np.float64)))) - groesste2 = max(groesste2, abstand) - if ungleich2 <= 3: - stellen = np.flatnonzero(soll != ist)[:5] - print( - f" Teil 2 ungleich: Start {start}, Schritt {k}, Position {position}, " - f"Stellen {stellen.tolist()}, groesster Abstand {abstand:.3e}" - ) - if beendet or abgeschnitten: - break - env.close() - - print(f"Teil 2: {verglichen2} Beobachtungen verglichen, {ungleich2} ungleich, groesster Abstand {groesste2:.3e}") - print(f" gesehene Zustaende: {zustand}") - if not (zustand["long"] and zustand["short"] and zustand["kontowert_ungleich"] and zustand["zaehler_groesser_null"]): - print("BEFUND: Teil 2 hat nicht jeden Zustand gesehen (long, short, Kontowert ungleich, Zaehler groesser null)") - return 2 - if ungleich or ungleich2: - print("BEFUND: die Beobachtung des Runners weicht von der der NautilusEnv ab") - return 2 - return 0 - - -if __name__ == "__main__": - try: - sys.exit(main()) - except Exception as ausnahme: # noqa: BLE001 - ein Absturz des Pruefskripts ist selbst ein Befund - print(f"BEFUND: das Pruefskript ist abgebrochen: {ausnahme!r}") - sys.exit(2) diff --git a/.vault-tools/trading-plattform-pruefung/pruef_teil3b.py b/.vault-tools/trading-plattform-pruefung/pruef_teil3b.py deleted file mode 100644 index 6131377..0000000 --- a/.vault-tools/trading-plattform-pruefung/pruef_teil3b.py +++ /dev/null @@ -1,394 +0,0 @@ -"""Eigener Lauf mit echten Daten zu Teil 3b: Liste und Einzelansicht der Laeufe, Kerzen. - -Koordinator-Pruefskript, nicht fuers Repo. Laeuft in WSL aus dem Projektordner (PYTHONPATH=.). - -Geltungsbereich und Herkunft jeder Antwort: - F1 Die Liste nennt jede *.jsonl der konfigurierten Ordner genau einmal. Quelle: eigenes glob - F2 art und zeit je Eintrag gleich der ersten Zeile der Datei. Quelle: eigener Vollparse - F3 vollstaendig genau dann, wenn die letzte Zeile ergebnis/zusammenfassung. Projektvorgabe (Spec) - F4 Jede Zahl der Abschlusszeilen gleich dem eigenen Vollparse, ausser den - ausdruecklich weggelassenen Schluesseln beste_zelle, beste_zelle_je_gruppe. Projektvorgabe (Spec) - F5 Einzelansicht: Auslassungen (kurven, zeitstempel, startpunkte, training), - Zellen je Symbol und Zaehlungen je Symbol gleich eigener Zaehlung. Projektvorgabe (Spec) - F6 Kerzen: Zusammenfassung ab 09:30 New York gleich eigener pandas-Rechnung - aus demselben Rohabruf; Achsenwert = Berliner Wanduhr; Tagesstrings. Projektvorgabe (Spec) -Was es nicht sieht: die Oberflaeche, den WebSocket, die Skripte. Es ist ein Fangnetz, kein Nachweis. -Regressionsnachweis: --sabotage verfaelscht je einen Wert der Antwort vor dem Vergleich; der Lauf -muss dann mit Exit 2 und der passenden Meldung enden. -""" - -import argparse -import json -import math -import sys -import tempfile -import time -import traceback -from pathlib import Path - -BEFUNDE: list[str] = [] - - -def befund(text: str) -> None: - BEFUNDE.append(text) - print("BEFUND:", text) - - -def gleich(a, b) -> bool: - # FastAPI 0.141.1 serialisiert ueber pydantic und macht aus NaN und Infinity ein null - # (gemessen 2026-09-17); der eigene Vollparse liefert dafuer float('nan') oder inf. - if a is None and isinstance(b, float) and not math.isfinite(b): - return True - if b is None and isinstance(a, float) and not math.isfinite(a): - return True - if isinstance(a, float) and isinstance(b, float): - if math.isnan(a) and math.isnan(b): - return True - return a == b - if isinstance(a, dict) and isinstance(b, dict): - return a.keys() == b.keys() and all(gleich(a[k], b[k]) for k in a) - if isinstance(a, list) and isinstance(b, list): - return len(a) == len(b) and all(gleich(x, y) for x, y in zip(a, b)) - return a == b - - -def vollparse(pfad: Path) -> list[dict]: - zeilen = [] - with pfad.open("rb") as strom: - for roh in strom: - roh = roh.rstrip(b"\n") - if roh.strip(): - zeilen.append(json.loads(roh.decode("utf-8"))) - return zeilen - - -def baue_client(): - from fastapi.testclient import TestClient - - from trading.broker.attrappe import BrokerAttrappe - from trading.dashboard.app import erzeuge_app - from trading.dashboard.config import lade_dashboard_config - from trading.killswitch.killswitch import lade_killswitch_config - from trading.registry.registry import Registry - - config = lade_dashboard_config("config/dashboard.toml") - temp = Path(tempfile.mkdtemp(prefix="pruef3b-")) - registry = Registry(db_pfad=temp / "registry.db", artefakt_wurzel=temp / "artifacts") - app = erzeuge_app( - broker=BrokerAttrappe(), - registry=registry, - config=config, - killswitch_config=lade_killswitch_config("config/killswitch.toml"), - dist=Path("dashboard/dist"), - ) - return TestClient(app), config - - -ABSCHLUSS = { - "kampagne": ("walk_forward", "ergebnis"), - "signal": ("zusammenfassung",), - "signal_tage": ("zusammenfassung",), -} -LETZTE = {"kampagne": "ergebnis", "signal": "zusammenfassung", "signal_tage": "zusammenfassung"} -WEGGELASSEN = ("beste_zelle", "beste_zelle_je_gruppe") - - -def pruefe_laeufe(client, config, sabotage: str | None) -> None: - t0 = time.perf_counter() - antwort = client.get("/api/laeufe") - dauer = time.perf_counter() - t0 - if antwort.status_code != 200: - befund(f"/api/laeufe antwortet {antwort.status_code}") - return - daten = antwort.json() - liste = daten["laeufe"] - print(f"/api/laeufe: {len(liste)} Eintraege in {dauer:.3f} s, hinweise={daten.get('hinweise')}") - - if sabotage == "zahl": - for eintrag in liste: - if eintrag["art"] == "kampagne" and eintrag["ergebnis"]: - eintrag["ergebnis"]["walk_forward"]["sharpe"] += 1e-9 - break - if sabotage == "fehlt": - liste.pop() - if sabotage == "art": - liste[0]["art"] = "signal" if liste[0]["art"] != "signal" else "kampagne" - - dateien = {} - for ordner in config.laeufe_ordner: - for pfad in sorted(Path(ordner).glob("*.jsonl")): - dateien[(Path(ordner).name, pfad.name)] = pfad - gelistet = [(e["quelle"], e["datei"]) for e in liste] - if sorted(gelistet) != sorted(dateien): - befund(f"F1: Liste und Ordner weichen ab: nur Liste {set(gelistet) - set(dateien)}, nur Ordner {set(dateien) - set(gelistet)}") - if len(gelistet) != len(set(gelistet)): - befund("F1: doppelte Eintraege in der Liste") - - zeiten = [e["zeit"] for e in liste if e["zeit"] is not None] - if zeiten != sorted(zeiten, reverse=True): - befund("Sortierung: zeit ist nicht absteigend") - - arten: dict[str, int] = {} - for eintrag in liste: - schluessel = (eintrag["quelle"], eintrag["datei"]) - pfad = dateien.get(schluessel) - if pfad is None: - continue - zeilen = vollparse(pfad) - kopf = zeilen[0] - arten[eintrag["art"]] = arten.get(eintrag["art"], 0) + 1 - if eintrag["art"] != kopf.get("art"): - befund(f"F2 {pfad.name}: art {eintrag['art']!r} gegen {kopf.get('art')!r}") - continue - if eintrag["zeit"] != kopf.get("zeit"): - befund(f"F2 {pfad.name}: zeit {eintrag['zeit']!r} gegen {kopf.get('zeit')!r}") - if eintrag["groesse_bytes"] != pfad.stat().st_size: - befund(f"{pfad.name}: groesse_bytes {eintrag['groesse_bytes']} gegen {pfad.stat().st_size}") - art = kopf.get("art") - erwartet_voll = zeilen[-1].get("art") == LETZTE.get(art) - if eintrag["vollstaendig"] != erwartet_voll: - befund(f"F3 {pfad.name}: vollstaendig {eintrag['vollstaendig']} gegen {erwartet_voll}") - for name in ABSCHLUSS.get(art, ()): - eigene = [z for z in zeilen if z.get("art") == name] - if not eigene: - continue - soll = {k: v for k, v in eigene[-1].items() if k not in WEGGELASSEN} - ist = (eintrag["ergebnis"] or {}).get(name) - if ist is None: - befund(f"F4 {pfad.name}: Abschlusszeile {name} fehlt in der Liste") - elif not gleich(ist, soll): - abweichend = [k for k in soll if k not in ist or not gleich(ist[k], soll[k])] - zusaetzlich = [k for k in ist if k not in soll] - befund(f"F4 {pfad.name}: {name} weicht ab in {abweichend}, zusaetzlich {zusaetzlich}") - kopf_ist = eintrag["kopf"] - kopf_soll = json.loads(json.dumps(kopf)) - if art == "signal_tage": - kopf_soll.get("daten", {}).pop("instrumente", None) - if not gleich(kopf_ist, kopf_soll): - befund(f"{pfad.name}: kopf weicht ab in {[k for k in kopf_soll if not gleich(kopf_ist.get(k), kopf_soll[k])]}") - print("Arten:", arten) - - -def pruefe_einzelansicht(client, config, sabotage: str | None) -> None: - kandidaten = [ - ("kampagne", "laeufe", "aapl-minute-ppo-v20260914-132555.jsonl", None), - # Traegt 30 Zeilen mit "gewinnfaktor": NaN (Protokollschreiber vor dem Fix vom 2026-09-14). - ("kampagne", "laeufe", "aapl-minute-ppo-v20260914-085048.jsonl", None), - ("signal", "signal", "signal-20260914-213950.jsonl", None), - ("signal_tage", "signal", "tage-teil9-20260917-030800.jsonl", "MSFT"), - ] - ordner = {Path(o).name: Path(o) for o in config.laeufe_ordner} - for art, quelle, datei, symbol in kandidaten: - pfad = ordner[quelle] / datei - if not pfad.exists(): - befund(f"Voraussetzung fehlt: {pfad}") - continue - adresse = f"/api/laeufe/{quelle}/{datei}" + (f"?symbol={symbol}" if symbol else "") - t0 = time.perf_counter() - antwort = client.get(adresse) - dauer = time.perf_counter() - t0 - if antwort.status_code != 200: - befund(f"{adresse} antwortet {antwort.status_code}") - continue - daten = antwort.json() - print(f"{adresse}: {len(antwort.content):,} Bytes in {dauer:.3f} s, Zeilenarten { {k: len(v) for k, v in daten['zeilen'].items()} }, unlesbar {daten.get('unlesbare_zeilen')}") - zeilen = vollparse(pfad) - if daten["art"] != art: - befund(f"F5 {datei}: art {daten['art']!r}") - if not gleich(daten["kopf"], zeilen[0]): - befund(f"F5 {datei}: kopf der Einzelansicht ist nicht die ganze Kopfzeile") - if sabotage == "zelle" and art == "signal_tage": - daten["zeilen"]["zelle_instrument"].pop() - if sabotage == "zaehlung" and art == "signal_tage": - daten["symbole"][0]["k1"] += 1 - - if art == "kampagne": - if "training" in daten["zeilen"] and daten["zeilen"]["training"]: - befund("F5: training-Zeilen sind in der Einzelansicht") - eigene = [z for z in zeilen if z.get("art") == "fenster"] - ist = daten["zeilen"].get("fenster", []) - if len(ist) != len(eigene): - befund(f"F5: {len(ist)} fenster gegen {len(eigene)}") - for a, b in zip(ist, eigene): - soll = {k: v for k, v in b.items() if k not in ("kurven", "zeitstempel", "startpunkte")} - if not gleich(a, soll): - befund(f"F5: fenster {b.get('nummer')} weicht ab oder traegt Masse: {sorted(set(a) ^ set(soll))}") - break - for name in ("walk_forward", "ergebnis"): - if not gleich(daten["zeilen"].get(name, []), [z for z in zeilen if z.get("art") == name]): - befund(f"F5: {name} weicht ab") - if art == "signal": - for name in ("zelle", "permutation", "zusammenfassung"): - if not gleich(daten["zeilen"].get(name, []), [z for z in zeilen if z.get("art") == name]): - befund(f"F5 signal: {name} weicht ab") - if art == "signal_tage": - for name in ("diagnose", "zelle_pool", "jahr", "permutation", "zusammenfassung"): - if not gleich(daten["zeilen"].get(name, []), [z for z in zeilen if z.get("art") == name]): - befund(f"F5 signal_tage: {name} weicht ab") - eigene = [z for z in zeilen if z.get("art") == "zelle_instrument" and z.get("symbol") == symbol] - if not gleich(daten["zeilen"].get("zelle_instrument", []), eigene): - befund(f"F5 signal_tage: Zellen fuer {symbol}: {len(daten['zeilen'].get('zelle_instrument', []))} gegen {len(eigene)}") - print(f" Zellen {symbol}: {len(eigene)}") - zaehlung: dict[str, dict] = {} - for z in zeilen: - if z.get("art") != "zelle_instrument": - continue - s = zaehlung.setdefault(z["symbol"], {"symbol": z["symbol"], "gruppe": z.get("gruppe"), "zellen": 0, "t_ab_2": 0, "k1": 0, "k2": 0, "signale": 0}) - s["zellen"] += 1 - t = z.get("t_ic_massgeblich") - if isinstance(t, (int, float)) and not math.isnan(t) and abs(t) >= 2: - s["t_ab_2"] += 1 - s["k1"] += 1 if z.get("K1") is True else 0 - s["k2"] += 1 if z.get("K2") is True else 0 - s["signale"] += 1 if z.get("signal") is True else 0 - ist = {s["symbol"]: s for s in daten.get("symbole", [])} - if ist.keys() != zaehlung.keys(): - befund(f"F5 symbole: Menge weicht ab: {set(ist) ^ set(zaehlung)}") - for name, soll in zaehlung.items(): - if name in ist and not gleich({k: ist[name].get(k) for k in soll}, soll): - befund(f"F5 symbole {name}: {ist[name]} gegen {soll}") - print(f" Symbole: {len(zaehlung)}, Summe Signale {sum(s['signale'] for s in zaehlung.values())}, Summe K1 {sum(s['k1'] for s in zaehlung.values())}") - ohne = client.get(f"/api/laeufe/{quelle}/{datei}").json() - if ohne["zeilen"].get("zelle_instrument"): - befund("F5: ohne symbol kommen Instrumentenzellen") - - for adresse in ("/api/laeufe/signal/gibt-es-nicht.jsonl", "/api/laeufe/fremd/signal-20260914-213950.jsonl", "/api/laeufe/signal/..%2F..%2Fconfig%2Fsecrets.toml"): - status = client.get(adresse).status_code - print(f"{adresse}: {status}") - if status != 404: - befund(f"Pfadsicherheit: {adresse} antwortet {status} statt 404") - - -def pruefe_kerzen(client, config, sabotage: str | None) -> None: - import numpy as np - import pandas as pd - - from trading.data.catalog import Catalog - - antwort = client.get("/api/markt/datensaetze") - if antwort.status_code != 200: - befund(f"/api/markt/datensaetze antwortet {antwort.status_code}") - return - saetze = antwort.json()["datensaetze"] - print(f"/api/markt/datensaetze: {len(saetze)} Datensaetze") - katalog = Catalog(config.markt_katalog) - if sorted(s["bar_type"] for s in saetze) != katalog.bar_types(): - befund("F6: Datensaetze weichen von Catalog.bar_types() ab") - minute = [s for s in saetze if s["bar_type"] == "AAPL.XNAS-1-MINUTE-LAST-EXTERNAL"] - if not minute or minute[0].get("aufloesungen_min") != [1, 5, 15, 60]: - befund(f"F6: aufloesungen_min des Minutendatensatzes: {minute[0].get('aufloesungen_min') if minute else 'fehlt'}") - - tag = "MSFT.XNAS-1-DAY-LAST-EXTERNAL" - t0 = time.perf_counter() - antwort = client.get("/api/markt/kerzen", params={"bar_type": tag, "aufloesung_min": 1, "anzahl": 3000}) - dauer = time.perf_counter() - t0 - if antwort.status_code != 200: - befund(f"Tageskerzen antworten {antwort.status_code}: {antwort.text[:300]}") - else: - kerzen = antwort.json()["kerzen"] - roh = katalog.read(tag) - print(f"Tageskerzen {tag}: {len(kerzen)} in {dauer:.3f} s (Katalog {len(roh)})") - if len(kerzen) != len(roh): - befund(f"F6 Tage: {len(kerzen)} gegen {len(roh)}") - for k, b in list(zip(kerzen, roh))[:: max(1, len(roh) // 200)]: - soll_tag = pd.Timestamp(b.ts_event, unit="ns", tz="UTC").tz_convert("America/New_York").strftime("%Y-%m-%d") - if k["achse"] != soll_tag or not gleich([k["open"], k["high"], k["low"], k["close"], k["volumen"]], [float(b.open), float(b.high), float(b.low), float(b.close), float(b.volume)]): - befund(f"F6 Tage: Kerze {k} gegen {b}") - break - - minute_typ = "AAPL.XNAS-1-MINUTE-LAST-EXTERNAL" - for aufloesung, ende, anzahl in [(1, "2026-09-10", 500), (5, "2026-09-10", 500), (15, "2026-03-13", 400), (60, "2025-11-05", 300), (60, "2026-09-10", 3000)]: - t0 = time.perf_counter() - antwort = client.get("/api/markt/kerzen", params={"bar_type": minute_typ, "aufloesung_min": aufloesung, "ende": ende, "anzahl": anzahl}) - dauer = time.perf_counter() - t0 - if antwort.status_code != 200: - befund(f"Minuten {aufloesung}/{ende}: {antwort.status_code} {antwort.text[:300]}") - continue - kerzen = antwort.json()["kerzen"] - if sabotage == "kerze" and aufloesung == 5: - kerzen[3]["high"] += 0.01 - if sabotage == "achse" and aufloesung == 15: - kerzen[5]["achse"] += 3600 - - bis = pd.Timestamp(ende, tz="UTC") + pd.Timedelta(days=1) - von = bis - pd.Timedelta(days=int(anzahl * aufloesung / 390 * 1.6) + 30) - roh = katalog.read(minute_typ, von, bis) - rahmen = pd.DataFrame( - { - "ts": [b.ts_event for b in roh], - "open": [float(b.open) for b in roh], - "high": [float(b.high) for b in roh], - "low": [float(b.low) for b in roh], - "close": [float(b.close) for b in roh], - "volumen": [float(b.volume) for b in roh], - } - ) - ny = pd.to_datetime(rahmen["ts"], unit="ns", utc=True).dt.tz_convert("America/New_York") - rahmen = rahmen[ny.dt.strftime("%Y-%m-%d") <= ende].copy() - ny = ny[rahmen.index] - minuten = ny.dt.hour * 60 + ny.dt.minute - (9 * 60 + 30) - rahmen["tag"] = ny.dt.strftime("%Y-%m-%d") - rahmen["eimer"] = (minuten // aufloesung).astype(int) - gruppen = rahmen.groupby(["tag", "eimer"], sort=True) - eigen = gruppen.agg(open=("open", "first"), high=("high", "max"), low=("low", "min"), close=("close", "last"), volumen=("volumen", "sum")).reset_index() - anfang = pd.to_datetime(eigen["tag"]).dt.tz_localize("America/New_York") + pd.to_timedelta(9 * 60 + 30 + eigen["eimer"] * aufloesung, unit="m") - eigen["zeit_utc"] = anfang.dt.tz_convert("UTC") - berlin = anfang.dt.tz_convert("Europe/Berlin").dt.tz_localize(None) - eigen["achse"] = (berlin - pd.Timestamp("1970-01-01")) // pd.Timedelta(seconds=1) - eigen = eigen.tail(anzahl).reset_index(drop=True) - print(f"Minuten {aufloesung:>2} min bis {ende}: {len(kerzen)} Kerzen in {dauer:.3f} s (eigene Rechnung {len(eigen)}, Rohkerzen {len(roh)})") - if len(kerzen) != len(eigen): - befund(f"F6 {aufloesung} min bis {ende}: {len(kerzen)} Kerzen gegen {len(eigen)}") - continue - for i, k in enumerate(kerzen): - e = eigen.iloc[i] - zeit_ist = pd.Timestamp(k["zeit"]) - werte_ist = np.array([k["open"], k["high"], k["low"], k["close"], k["volumen"]], dtype=float) - werte_soll = np.array([e["open"], e["high"], e["low"], e["close"], e["volumen"]], dtype=float) - if zeit_ist != e["zeit_utc"] or int(k["achse"]) != int(e["achse"]) or not np.allclose(werte_ist, werte_soll, rtol=0, atol=1e-9): - befund(f"F6 {aufloesung} min bis {ende}, Kerze {i}: ist {k} soll zeit {e['zeit_utc']} achse {int(e['achse'])} werte {werte_soll.tolist()}") - break - achsen = [k["achse"] for k in kerzen] - if achsen != sorted(set(achsen)): - befund(f"F6 {aufloesung} min bis {ende}: Achsenwerte nicht streng steigend") - - for params, erwartet in [ - ({"bar_type": "GIBT.ES-NICHT", "aufloesung_min": 1, "anzahl": 10}, 404), - ({"bar_type": minute_typ, "aufloesung_min": 7, "anzahl": 10}, 422), - ({"bar_type": minute_typ, "aufloesung_min": 5, "anzahl": 0}, 422), - ({"bar_type": minute_typ, "aufloesung_min": 5, "anzahl": 999999}, 422), - ({"bar_type": minute_typ, "aufloesung_min": 5, "anzahl": 10, "ende": "kein-datum"}, 422), - ({"bar_type": tag, "aufloesung_min": 5, "anzahl": 10}, 422), - ]: - status = client.get("/api/markt/kerzen", params=params).status_code - print(f"{params}: {status}") - if status != erwartet: - befund(f"F6 Fehlerfall {params}: {status} statt {erwartet}") - - -def main() -> int: - zerleger = argparse.ArgumentParser() - zerleger.add_argument("teil", choices=["laeufe", "einzel", "kerzen", "alles"]) - zerleger.add_argument("--sabotage", choices=["zahl", "fehlt", "art", "zelle", "zaehlung", "kerze", "achse"]) - argumente = zerleger.parse_args() - try: - client, config = baue_client() - with client: - if argumente.teil in ("laeufe", "alles"): - pruefe_laeufe(client, config, argumente.sabotage) - if argumente.teil in ("einzel", "alles"): - pruefe_einzelansicht(client, config, argumente.sabotage) - if argumente.teil in ("kerzen", "alles"): - pruefe_kerzen(client, config, argumente.sabotage) - except Exception: - befund("unerwarteter Abbruch:\n" + traceback.format_exc()) - print() - if BEFUNDE: - print(f"{len(BEFUNDE)} Befund(e).") - return 2 - print("Kein Befund.") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/pruef_ws.py b/.vault-tools/trading-plattform-pruefung/pruef_ws.py deleted file mode 100644 index 4ef67b9..0000000 --- a/.vault-tools/trading-plattform-pruefung/pruef_ws.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Eigener Lauf zu Teil 3b: Bekommen zwei Verbindungen am laufenden Backend denselben Snapshot? - -Koordinator-Pruefskript, nicht fuers Repo. Laeuft in WSL gegen ws://127.0.0.1:8144/ws/uebersicht. -Frage: Tragen die Snapshots beider Verbindungen dieselben zeit-Werte (ein Bau je Takt), und kommt -der erste Snapshot einer spaeteren Verbindung sofort? Herkunft: Projektvorgabe (Spec, C4a). -Was es nicht sieht: die Zahl der Broker-Anfragen selbst (die zaehlt der Test mit der Attrappe). -""" - -import asyncio -import json -import sys -import time - -import websockets - -PORT = sys.argv[1] if len(sys.argv) > 1 else "8144" -ADRESSE = f"ws://127.0.0.1:{PORT}/ws/uebersicht" - - -async def lies(name: str, anzahl: int, ergebnis: dict) -> None: - t0 = time.monotonic() - async with websockets.connect(ADRESSE) as strom: - zeiten = [] - for i in range(anzahl): - daten = json.loads(await strom.recv()) - if i == 0: - ergebnis[name + "_erste_nach_s"] = round(time.monotonic() - t0, 3) - zeiten.append(daten["zeit"]) - ergebnis[name] = zeiten - - -async def main() -> int: - ergebnis: dict = {} - erste = asyncio.create_task(lies("a", 4, ergebnis)) - await asyncio.sleep(2.0) - zweite = asyncio.create_task(lies("b", 3, ergebnis)) - await asyncio.gather(erste, zweite) - print(json.dumps(ergebnis, indent=2)) - gemeinsam = set(ergebnis["a"]) & set(ergebnis["b"]) - print("gemeinsame zeit-Werte:", len(gemeinsam)) - befunde = [] - if len(gemeinsam) < 2: - befunde.append("die zwei Verbindungen teilen weniger als zwei Snapshots") - if ergebnis["b"][0] not in ergebnis["a"]: - befunde.append("der erste Snapshot der spaeteren Verbindung ist kein Snapshot der ersten") - if ergebnis["b_erste_nach_s"] > 1.0: - befunde.append(f"die spaetere Verbindung wartete {ergebnis['b_erste_nach_s']} s auf ihren ersten Snapshot") - for text in befunde: - print("BEFUND:", text) - return 2 if befunde else 0 - - -if __name__ == "__main__": - try: - sys.exit(asyncio.run(main())) - except Exception as ausnahme: - print("BEFUND: unerwarteter Abbruch:", repr(ausnahme)) - sys.exit(2) diff --git a/.vault-tools/trading-plattform-pruefung/real_pytest.sh b/.vault-tools/trading-plattform-pruefung/real_pytest.sh deleted file mode 100644 index 41579c6..0000000 --- a/.vault-tools/trading-plattform-pruefung/real_pytest.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -# Faehrt pytest im echten Projektordner. Aufruf: wsl -d Debian -- bash -s -- < real_pytest.sh -set -u -cd "/mnt/d/Claude/Code Projekte/active - Trading Plattform" || exit 9 -"$HOME/venvs/trading/bin/python" -m pytest -p no:cacheprovider "$@" < /dev/null 2> /tmp/real_pytest_stderr.txt | grep -v -E "Pandas4Warning|utcnow|^$" | tail -12 -echo "--- stderr (ohne Warnungen) ---" -grep -v -E "Pandas4Warning|utcnow" /tmp/real_pytest_stderr.txt | tail -10 -echo "Task-destroyed-Meldungen: $(grep -c 'Task was destroyed' /tmp/real_pytest_stderr.txt)" diff --git a/.vault-tools/trading-plattform-pruefung/rueckweg_aapl.py b/.vault-tools/trading-plattform-pruefung/rueckweg_aapl.py deleted file mode 100644 index 13366bb..0000000 --- a/.vault-tools/trading-plattform-pruefung/rueckweg_aapl.py +++ /dev/null @@ -1,236 +0,0 @@ -"""Rueckweg des Paper-Runners von zehn Aktien auf AAPL, einmal, nach Pascals Vorgabe. - -Koordinator-Werkzeug, nicht fuers Repo. Pascal am 2026-09-24: zehn Aktien "bis Mittwoch" -- gelesen als -bis einschliesslich Mittwoch 2026-09-30; ab Donnerstag 2026-10-01 handelt der Runner wieder AAPL mit der -Registerversion. Der Rueckweg ist der aus Paket M: config/paper-aapl-register.toml wird config/paper.toml. -Geplante Aufgabe "Trading Plattform - Rueckweg AAPL" (rueckweg_aapl_einrichten.ps1): 2026-09-30 22:50, -zweiter Versuch 2026-10-01 22:10, verpasste Termine beim naechsten Start (StartWhenAvailable). - -Schritte, jeder mit seinem Grund: - 1. Nicht vor FRUEHESTENS (nach Boersenschluss des letzten Tages mit zehn Aktien). - 2. Ergebnisdatei ABLAGE/rueckweg.json da -> schon erledigt, nichts tun. - 3. Laeuft der Runner (systemctl is-active trading-paper.service in WSL), wird nichts getauscht: Er hat - seine Konfiguration beim Start gelesen, und ein Tausch mitten am Tag wirkte erst beim naechsten Start. - Befund, der zweite Termin versucht es erneut. - Stand er, wird nach WARTEN_S noch einmal gefragt: Hat erst dieser Aufruf WSL geweckt, startet der - Zeitgeber den Runner 60 s danach (OnStartupSec=60) und liest dabei die Konfiguration. - 4. Im Projektordner muss ZWEIG ausgecheckt sein, sonst Befund. - 5. config/paper.toml gleich der Rueckweg-Datei: sauberer Ordner -> schon getauscht (Ergebnisdatei, - Ende); nur config/paper.toml geaendert -> halb erledigt (ein gescheiterter Commit), weiter mit 8; - sonst Befund. - 6. Sonst muss der Ordner sauber sein (git status --porcelain leer), sonst Befund: Ein Commit ueber - fremde Aenderungen waere schlimmer als ein Tag mehr mit zehn Aktien. Und config/paper.toml muss die - Konfiguration mit mehreren Aktien sein (Zeile 'quelle = "folge"'), sonst Befund. - 7. Jeder Schluessel aus config/paper.toml ausser den erwarteten Unterschieden (ERWARTET_ANDERS) muss - in der Rueckweg-Datei mit demselben Wert stehen, sonst Befund: Der Tausch verloere ihn still oder - schaltete ihn still um (etwa [auto_fortsetzen] aktiv = false, von Pascal gesetzt). - 8. Bytes kopieren, git add, git commit (lokal; gepusht wird von der naechsten Sitzung -- ein Push aus - einer Aufgabe ohne Fenster koennte an einer Anmeldeabfrage haengen). - 9. Eine Zeile in Inbox/.md unter "## Agentenprotokoll"; bei einem Befund zusaetzlich eine Meldung - mit hoher Prioritaet ueber abendkontrolle_wsl.py --meldung. -Aufruf von Hand: python rueckweg_aapl.py [--jetzt ISO] [--projekt PFAD] [--vault PFAD] [--ohne-wsl] -""" - -import argparse -import json -import subprocess -import sys -import time -import tomllib -import traceback -from datetime import datetime -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from abendkontrolle import KEIN_FENSTER # noqa: E402 -from abendkontrolle import WSL # noqa: E402 -from abendkontrolle import WSL_PY # noqa: E402 -from abendkontrolle import WSL_SKRIPT # noqa: E402 -from abendkontrolle import schreibe_notiz # noqa: E402 - -VAULT = Path(r"D:\Claude") -PROJEKT = VAULT / "Code Projekte" / "active - Trading Plattform" -GIT = r"C:\Program Files\Git\cmd\git.exe" -FRUEHESTENS = datetime(2026, 9, 30, 22, 5) -ZIEL = Path("config") / "paper.toml" -RUECKWEG = Path("config") / "paper-aapl-register.toml" -MEHRERE = 'quelle = "folge"' -ZWEIG = "master" -ERWARTET_ANDERS = ("agent.", "umgebung.instrumente", "umgebung.instrument", "grenzen.max_tagesverlust_anteil") -WARTEN_S = 90 -NOCHMAL = "Steht noch ein Termin der Aufgabe aus, versucht er es erneut; sonst entscheidet die naechste Sitzung." -NACHRICHT = ( - "Rueckweg auf AAPL: config/paper.toml aus config/paper-aapl-register.toml\n\n" - "Pascal am 2026-09-24: zehn Aktien bis Mittwoch 2026-09-30. Ab 2026-10-01 handelt der Runner wieder\n" - "AAPL mit der Registerversion (Rueckweg aus Paket M). Automatisch durch die geplante Aufgabe\n" - "'Trading Plattform - Rueckweg AAPL' (.vault-tools/trading-plattform-pruefung/rueckweg_aapl.py).\n\n" - "Co-Authored-By: Claude Opus 5.5 \n" -) - - -def git(projekt: Path, *argumente: str) -> subprocess.CompletedProcess: - return subprocess.run( - [GIT, "-C", str(projekt), *argumente], capture_output=True, text=True, encoding="utf-8", - errors="replace", timeout=120, creationflags=KEIN_FENSTER, - env={**__import__("os").environ, "GIT_TERMINAL_PROMPT": "0", "GCM_INTERACTIVE": "never"}, - ) - - -def runner_aktiv(vorgabe: str | None) -> bool: - if vorgabe is not None: - zustand = vorgabe # Angriffsfaelle: die Ausgabe von systemctl is-active, ohne WSL - else: - lauf = subprocess.run( - [WSL, "-d", "Debian", "-u", "claude", "--", "systemctl", "is-active", "trading-paper.service"], - capture_output=True, timeout=60, creationflags=KEIN_FENSTER, - ) - zustand = lauf.stdout.decode("utf-8", errors="replace").strip() - if zustand in ("active", "activating", "deactivating", "reloading"): - return True - if zustand in ("inactive", "failed"): - return False - raise RuntimeError(f"Zustand des Runners nicht lesbar: {zustand!r}") - - -def melde(titel: str, text: str, ohne_wsl: bool) -> str: - if ohne_wsl: - return "ohne WSL, nicht gesendet" - lauf = subprocess.run( - [WSL, "-d", "Debian", "-u", "claude", "--", WSL_PY, WSL_SKRIPT, "--meldung", titel, text], - capture_output=True, timeout=120, creationflags=KEIN_FENSTER, - ) - return lauf.stdout.decode("utf-8", errors="replace").strip()[-200:] - - -def blaetter(datei: Path) -> dict: - """Alle Blattschluessel einer TOML-Datei als Punktpfad mit ihrem Wert.""" - def ablaufen(tabelle: dict, pfad: str): - for name, wert in tabelle.items(): - voll = f"{pfad}.{name}" if pfad else name - if isinstance(wert, dict): - yield from ablaufen(wert, voll) - else: - yield voll, wert - with datei.open("rb") as f: - return dict(ablaufen(tomllib.load(f), "")) - - -def fehlende_schluessel(ziel: Path, quelle: Path) -> list[str]: - """Was der Tausch ausser den erwarteten Unterschieden aendern wuerde. - - Projektvorgabe (Paket M, Rueckweg): Die beiden Dateien unterscheiden sich nur in [agent], - umgebung.instrumente (zehn Aktien) gegen umgebung.instrument (AAPL) und im Wert von - grenzen.max_tagesverlust_anteil (1 % gegen 0,2 %); verglichen am 2026-09-24 im Arbeitsbaum M. - Jeder andere Schluessel, der nur in config/paper.toml steht, ginge beim Tausch still verloren - (etwa [auto_fortsetzen] aus Paket AF oder etwas aus Z2), und jeder andere Wert, der sich - unterscheidet, sprang still um -- etwa auto_fortsetzen.aktiv, das Pascal in config/paper.toml - abgeschaltet hat (Befund der Plankritik AF, 2026-09-24). - """ - alt, neu = blaetter(ziel), blaetter(quelle) - befunde = [] - for name in sorted(alt.keys() | neu.keys()): - if name.startswith(ERWARTET_ANDERS): - continue - if name not in neu: - befunde.append(f"{name} fehlt in der Rueckweg-Datei") - elif name not in alt: - # Die Gegenrichtung (Nachkritik AF, 2026-09-24): Hat Pascal [auto_fortsetzen] in - # config/paper.toml geloescht -- laut Task 421 ein gueltiges Abschalten --, brachte der - # Tausch aktiv = true aus der Rueckweg-Datei still zurueck. - befunde.append(f"{name} steht nur in der Rueckweg-Datei") - elif neu[name] != alt[name]: - befunde.append(f"{name} anderer Wert") - return befunde - - -def rueckweg(jetzt: datetime, projekt: Path, ablage: Path, runner_vorgabe: str | None) -> tuple[str, str | None]: - """(Ergebnis fuer die Notiz, Befund oder None).""" - if jetzt < FRUEHESTENS: - return f"zu frueh ({jetzt:%Y-%m-%d %H:%M}), nichts getan", None - if (ablage / "rueckweg.json").exists(): - return "schon erledigt", None - if runner_aktiv(runner_vorgabe): - return "", f"Der Runner laeuft; config/paper.toml nicht getauscht. {NOCHMAL}" - if runner_vorgabe is None: - # Hat erst dieser Aufruf WSL geweckt, startet der Zeitgeber den Runner 60 s danach - # (OnStartupSec=60) mit der Konfiguration, die er dann liest. Also warten und noch einmal fragen. - time.sleep(WARTEN_S) - if runner_aktiv(None): - return "", f"Der Runner ist eben gestartet; config/paper.toml nicht getauscht. {NOCHMAL}" - zweig = git(projekt, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip() - if zweig != ZWEIG: - return "", f"Im Projektordner ist {zweig!r} ausgecheckt, nicht {ZWEIG}; nicht getauscht" - ziel, quelle = projekt / ZIEL, projekt / RUECKWEG - if not quelle.is_file(): - return "", f"{RUECKWEG.as_posix()} fehlt; nicht getauscht" - status = git(projekt, "status", "--porcelain") - if status.returncode != 0: - return "", f"git status scheiterte ({status.returncode}); nicht getauscht" - geaendert = [z for z in status.stdout.splitlines() if z.strip()] - if ziel.is_file() and ziel.read_bytes() == quelle.read_bytes(): - if not geaendert: - return "config/paper.toml ist schon die AAPL-Konfiguration", None - if geaendert != [f" M {ZIEL.as_posix()}"] and geaendert != [f"M {ZIEL.as_posix()}"]: - return "", f"Projektordner nicht sauber (git status: {' | '.join(geaendert)[:200]}); nicht getauscht" - # Halb erledigt: getauscht, aber nicht committet (etwa ein gescheiterter Commit beim ersten Termin). - else: - if geaendert: - return "", f"Projektordner nicht sauber (git status: {' | '.join(geaendert)[:200]}); nicht getauscht" - if MEHRERE not in ziel.read_text(encoding="utf-8"): - return "", "config/paper.toml ist nicht die Konfiguration mit mehreren Aktien; nicht getauscht, Pascal entscheidet" - fehlend = fehlende_schluessel(ziel, quelle) - if fehlend: - return "", ( - f"config/paper-aapl-register.toml weicht ausserhalb der erwarteten Stellen von config/paper.toml ab " - f"({', '.join(fehlend)[:200]}); nicht getauscht, Pascal entscheidet" - ) - ziel.write_bytes(quelle.read_bytes()) - for schritt in (("add", ZIEL.as_posix()), ("commit", "-m", NACHRICHT)): - lauf = git(projekt, *schritt) - if lauf.returncode != 0: - return "", ( - f"git {schritt[0]} scheiterte ({lauf.returncode}: {(lauf.stderr or lauf.stdout).strip()[:200]}); " - f"Datei ist getauscht, aber nicht committet. {NOCHMAL}" - ) - kopf = git(projekt, "rev-parse", "--short", "HEAD").stdout.strip() - return f"config/paper.toml auf AAPL (Register) zurueckgestellt, Commit {kopf} (nicht gepusht)", None - - -def main(argv=None) -> int: - zerleger = argparse.ArgumentParser(allow_abbrev=False) - zerleger.add_argument("--jetzt") - zerleger.add_argument("--projekt", default=str(PROJEKT)) - zerleger.add_argument("--vault", default=str(VAULT)) - zerleger.add_argument("--ohne-wsl", action="store_true", help="Angriffsfaelle: keine Meldung, Runner laut --runner-zustand") - zerleger.add_argument("--runner-zustand", help="Angriffsfaelle: Ausgabe von systemctl is-active statt WSL") - argumente = zerleger.parse_args(argv) - if argumente.ohne_wsl and argumente.runner_zustand is None: - argumente.runner_zustand = "inactive" - jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now() - projekt, vault = Path(argumente.projekt), Path(argumente.vault) - ablage = projekt / "data" / "paper" / "abendkontrollen" - ablage.mkdir(parents=True, exist_ok=True) - try: - ergebnis, befund = rueckweg(jetzt, projekt, ablage, argumente.runner_zustand) - except Exception as ausnahme: - ergebnis, befund = "", f"abgebrochen: {type(ausnahme).__name__}: {str(ausnahme)[:200]}" - with (ablage / "lauf.log").open("a", encoding="utf-8", newline="\n") as log: - log.write(traceback.format_exc()) - with (ablage / "lauf.log").open("a", encoding="utf-8", newline="\n") as log: - log.write(f"{datetime.now().isoformat(timespec='seconds')} rueckweg: {ergebnis or befund}\n") - if ergebnis in ("schon erledigt",) or ergebnis.startswith("zu frueh"): - return 0 - kopf = f"- {jetzt:%H:%M} Uhr — Rückweg auf AAPL (automatisch, Pascals Vorgabe: zehn Aktien bis 30.09.)" - if befund is None: - (ablage / "rueckweg.json").write_text( - json.dumps({"zeit": jetzt.isoformat(), "ergebnis": ergebnis}, ensure_ascii=False), encoding="utf-8" - ) - schreibe_notiz(vault, jetzt.date().isoformat(), f"{kopf}: {ergebnis}.") - return 0 - gemeldet = melde("Rueckweg AAPL nicht ausgefuehrt", befund, argumente.ohne_wsl) - schreibe_notiz(vault, jetzt.date().isoformat(), f"{kopf}: **nicht ausgeführt** — {befund} #needs-review (Meldung: {gemeldet})") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/rueckweg_aapl_angriffe.py b/.vault-tools/trading-plattform-pruefung/rueckweg_aapl_angriffe.py deleted file mode 100644 index ea9a02b..0000000 --- a/.vault-tools/trading-plattform-pruefung/rueckweg_aapl_angriffe.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Angriffsfaelle fuer rueckweg_aapl.py auf einem Wegwerf-Git-Repo. Koordinator-Werkzeug, nicht fuers Repo. - -Jeder Fall einzeln, zuerst die Grundlinie; ohne WSL (--ohne-wsl, Runnerzustand vorgegeben), ohne Meldung. -Aufruf unter Windows: python rueckweg_aapl_angriffe.py Exit 0, wenn jeder Fall wie erwartet ausgeht. -""" - -import subprocess -import sys -import tempfile -from pathlib import Path - -HIER = Path(__file__).resolve().parent -sys.path.insert(0, str(HIER)) -import rueckweg_aapl as rw # noqa: E402 - -# Abdichtung (Pruefung DH-Nachtrag, 2026-09-26): Die Faelle laufen mit --ohne-wsl. Bricht ein Mutant -# diesen Schalter, riefe melde() sonst den echten WSL-Teil mit --meldung, also ntfy. rueckweg_aapl -# holt WSL und WSL_SKRIPT als Wert aus abendkontrolle; deshalb werden sie hier im Modul ersetzt. -rw.WSL = str(Path(tempfile.gettempdir()) / "rueckweg-angriffe-kein-wsl" / "wsl.exe") -rw.WSL_SKRIPT = "/nicht-vorhanden/abendkontrolle_wsl.py" - -ZEHN = '[agent]\nquelle = "folge"\ninstrumente = ["AAPL.XNAS", "MSFT.XNAS"]\n' -AAPL = '[agent]\nquelle = "register"\ninstrument = "AAPL.XNAS"\n' -abweichungen: list[str] = [] - - -def repo(tmp: Path, paper: str | None = ZEHN, aapl: str | None = AAPL) -> Path: - projekt = tmp / "projekt" - (projekt / "config").mkdir(parents=True) - if paper is not None: - (projekt / "config" / "paper.toml").write_bytes(paper.encode("utf-8")) - if aapl is not None: - (projekt / "config" / "paper-aapl-register.toml").write_bytes(aapl.encode("utf-8")) - (projekt / ".gitignore").write_text("data/\n", encoding="utf-8") - for befehl in (["init", "-q", "-b", "master"], ["config", "user.email", "t@example.invalid"], ["config", "user.name", "Test"], - ["config", "core.autocrlf", "false"], ["add", "-A"], ["commit", "-q", "-m", "start"]): - subprocess.run([rw.GIT, "-C", str(projekt), *befehl], check=True, capture_output=True) - (tmp / "Inbox").mkdir() - return projekt - - -def fall(name, *, jetzt="2026-09-30T22:50:00", vorbereiten=None, runner="inactive", paper=ZEHN, aapl=AAPL, - erwartet_code, getauscht, notiz, marke, commits, grund=None): - with tempfile.TemporaryDirectory() as tmp: - tmp = Path(tmp) - projekt = repo(tmp, paper, aapl) - if vorbereiten: - vorbereiten(projekt) - vorher = subprocess.run([rw.GIT, "-C", str(projekt), "rev-list", "--count", "HEAD"], capture_output=True, text=True).stdout.strip() - code = rw.main(["--jetzt", jetzt, "--projekt", str(projekt), "--vault", str(tmp), "--ohne-wsl", "--runner-zustand", runner]) - paper_jetzt = (projekt / "config" / "paper.toml").read_text(encoding="utf-8") if (projekt / "config" / "paper.toml").exists() else None - notizen = list((tmp / "Inbox").glob("*.md")) - text = notizen[0].read_text(encoding="utf-8") if notizen else "" - hat_marke = (projekt / "data" / "paper" / "abendkontrollen" / "rueckweg.json").exists() - nachher = subprocess.run([rw.GIT, "-C", str(projekt), "rev-list", "--count", "HEAD"], capture_output=True, text=True).stdout.strip() - ist = { - "code": code, - "getauscht": paper_jetzt == AAPL, - "notiz": ("nicht ausgeführt" if "nicht ausgeführt" in text else ("ja" if "Rückweg auf AAPL" in text else "nein")), - "marke": hat_marke, - "commits": int(nachher) - int(vorher), - } - soll = {"code": erwartet_code, "getauscht": getauscht, "notiz": notiz, "marke": marke, "commits": commits} - ok = ist == soll and (grund is None or grund in text) - zeile = f"{'OK ' if ok else 'FEHL'} {name}: {ist}" + ("" if ok else f" SOLL {soll}") - print(zeile) - if not ok: - abweichungen.append(zeile) - - -fall("Grundlinie", erwartet_code=0, getauscht=True, notiz="ja", marke=True, commits=1) -fall("zu frueh", jetzt="2026-09-30T21:59:00", erwartet_code=0, getauscht=False, notiz="nein", marke=False, commits=0) -fall("Ergebnisdatei schon da", - vorbereiten=lambda p: ((p / "data" / "paper" / "abendkontrollen").mkdir(parents=True), (p / "data" / "paper" / "abendkontrollen" / "rueckweg.json").write_text("{}")), - erwartet_code=0, getauscht=False, notiz="nein", marke=True, commits=0) -fall("Runner aktiv", runner="active", erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) -fall("Runner deactivating", runner="deactivating", erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) -fall("Runnerzustand unlesbar", runner="", erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) -fall("Runner failed (steht)", runner="failed", erwartet_code=0, getauscht=True, notiz="ja", marke=True, commits=1) -fall("schon AAPL", paper=AAPL, erwartet_code=0, getauscht=True, notiz="ja", marke=True, commits=0) -fall("Ordner nicht sauber (fremde Aenderung)", - vorbereiten=lambda p: (p / "config" / "risk.toml").write_text("x\n"), - erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) -fall("paper.toml geaendert, nicht committet", - vorbereiten=lambda p: (p / "config" / "paper.toml").write_text(ZEHN + "# neu\n"), - erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) -fall("paper.toml ohne folge", paper='[agent]\nquelle = "halte"\n', - erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) -fall("Rueckweg-Datei fehlt", aapl=None, erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) -fall("zweiter Termin Donnerstag", jetzt="2026-10-01T22:10:00", erwartet_code=0, getauscht=True, notiz="ja", marke=True, commits=1) -# Befunde des Pruefers: halb erledigter Tausch, verlorener Abschnitt, falscher Zweig -fall("halb getauscht (Datei getauscht, nicht committet)", - vorbereiten=lambda p: (p / "config" / "paper.toml").write_bytes(AAPL.encode("utf-8")), - erwartet_code=0, getauscht=True, notiz="ja", marke=True, commits=1) -AUTO = "\n[auto_fortsetzen]\naktiv = true\n" -fall("halb getauscht und fremde Aenderung", - vorbereiten=lambda p: ((p / "config" / "paper.toml").write_bytes(AAPL.encode("utf-8")), (p / "config" / "risk.toml").write_text("x\n")), - erwartet_code=1, getauscht=True, notiz="nicht ausgeführt", marke=False, commits=0) -fall("Abschnitt nur in paper.toml", paper=ZEHN + AUTO, - erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) -fall("Abschnitt in beiden", paper=ZEHN + AUTO, aapl=AAPL + AUTO, - erwartet_code=0, getauscht=False, notiz="ja", marke=True, commits=1) -AUS = "\n[auto_fortsetzen]\naktiv = false\n" -fall("Automatik nur in paper.toml abgeschaltet", paper=ZEHN + AUS, aapl=AAPL + AUTO, - erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) -fall("Abschnitt in paper.toml geloescht, in der Rueckweg-Datei noch da", paper=ZEHN, aapl=AAPL + AUTO, - erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0, - grund="auto_fortsetzen.aktiv steht nur in der Rueckweg-Datei") -fall("anderer Wert in [grenzen]", paper=ZEHN + "\n[grenzen]\nmax_orders = 5\n", aapl=AAPL + "\n[grenzen]\nmax_orders = 10\n", - erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) -fall("Tagesverlust darf sich unterscheiden", - paper=ZEHN + "\n[grenzen]\nmax_tagesverlust_anteil = 0.01\n", aapl=AAPL + "\n[grenzen]\nmax_tagesverlust_anteil = 0.002\n", - erwartet_code=0, getauscht=False, notiz="ja", marke=True, commits=1) -fall("anderer Zweig ausgecheckt", - vorbereiten=lambda p: subprocess.run([rw.GIT, "-C", str(p), "checkout", "-q", "-b", "z2"], check=True), - erwartet_code=1, getauscht=False, notiz="nicht ausgeführt", marke=False, commits=0) - -print(f"ERGEBNIS: {len(abweichungen)} Abweichung(en)") -sys.exit(1 if abweichungen else 0) diff --git a/.vault-tools/trading-plattform-pruefung/rueckweg_aapl_einrichten.ps1 b/.vault-tools/trading-plattform-pruefung/rueckweg_aapl_einrichten.ps1 deleted file mode 100644 index 37fec5a..0000000 --- a/.vault-tools/trading-plattform-pruefung/rueckweg_aapl_einrichten.ps1 +++ /dev/null @@ -1,37 +0,0 @@ -# Richtet die einmalige Windows-Aufgabe "Trading Plattform - Rueckweg AAPL" ein oder entfernt sie. -# Koordinator-Werkzeug, nicht fuers Repo. Pascal 2026-09-24: zehn Aktien bis Mittwoch 2026-09-30. -# Zwei Termine: Mi 2026-09-30 22:50 und als zweiter Versuch Do 2026-10-01 22:10; StartWhenAvailable holt -# einen verpassten Termin beim naechsten Start nach. rueckweg_aapl.py tut nach dem ersten Erfolg nichts mehr. -# -# Aufruf: powershell -NoProfile -File rueckweg_aapl_einrichten.ps1 [-Aktion einrichten|entfernen] -param( - [ValidateSet("einrichten", "entfernen")] - [string]$Aktion = "einrichten" -) - -$Name = "Trading Plattform - Rueckweg AAPL" -if ($Aktion -eq "entfernen") { - Unregister-ScheduledTask -TaskName $Name -Confirm:$false - return -} - -$Py = "C:\Users\Pascal Michel\AppData\Local\Programs\Python\Python313\pythonw.exe" -$Skript = "D:\Claude\.vault-tools\trading-plattform-pruefung\rueckweg_aapl.py" -if (-not (Test-Path $Py)) { throw "Interpreter fehlt: $Py" } -if (-not (Test-Path $Skript)) { throw "Skript fehlt: $Skript" } - -$Benutzer = "$env:USERDOMAIN\$env:USERNAME" -$Aufruf = New-ScheduledTaskAction -Execute $Py -Argument ('"' + $Skript + '"') -WorkingDirectory "D:\Claude" -$Erster = New-ScheduledTaskTrigger -Once -At ([datetime]"2026-09-30T22:50:00") -$Zweiter = New-ScheduledTaskTrigger -Once -At ([datetime]"2026-10-01T22:10:00") -$Wer = New-ScheduledTaskPrincipal -UserId $Benutzer -LogonType Interactive -RunLevel Limited -$Regeln = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` - -ExecutionTimeLimit (New-TimeSpan -Minutes 15) -MultipleInstances IgnoreNew -Register-ScheduledTask -TaskName $Name -Action $Aufruf -Trigger @($Erster, $Zweiter) -Principal $Wer ` - -Settings $Regeln -Force ` - -Description "Rueckweg des Paper-Runners auf AAPL nach Pascals Vorgabe (zehn Aktien bis 2026-09-30). Siehe rueckweg_aapl.py." | Out-Null - -$Aufgabe = Get-ScheduledTask -TaskName $Name -"{0}: {1}" -f $Aufgabe.TaskName, $Aufgabe.State -$Aufgabe.Triggers | ForEach-Object { "Ausloeser: {0} {1}" -f $_.CimClass.CimClassName, $_.StartBoundary } -"Naechster Lauf: {0}" -f (Get-ScheduledTaskInfo -TaskName $Name).NextRunTime diff --git a/.vault-tools/trading-plattform-pruefung/runner_stand.py b/.vault-tools/trading-plattform-pruefung/runner_stand.py deleted file mode 100644 index e798618..0000000 --- a/.vault-tools/trading-plattform-pruefung/runner_stand.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Zeigt den Stand des laufenden Paper-Runners aus seinem Protokoll: Zeilen je Art, letzter Takt, Phase, Killswitch. - -Liest nur. Gibt keine Kontowerte aus, nur Zaehler, Zeiten, Phase, Zustand und Stueckzahl der Position. -Aufruf: python runner_stand.py [] (ohne Angabe das neueste *-paper.jsonl im echten Projektordner) -""" - -import json -import sys -from collections import Counter -from pathlib import Path - -ORDNER = Path("/mnt/d/Claude/Code Projekte/active - Trading Plattform/artifacts/paper") -ZEIGEN = ("zeit", "phase", "killswitch", "zustand", "position", "frisch", "letzte_kerze", "aktion", "stufe", "grund", "exit_code") - - -def main() -> int: - if len(sys.argv) > 1: - pfad = Path(sys.argv[1]) - else: - kandidaten = sorted(ORDNER.glob("*-paper.jsonl"), key=lambda p: p.stat().st_mtime) - if not kandidaten: - print("kein Protokoll gefunden") - return 3 - pfad = kandidaten[-1] - zeilen = [] - with open(pfad, encoding="utf-8") as datei: - for zeile in datei: - if zeile.strip(): - try: - zeilen.append(json.loads(zeile)) - except ValueError: - pass - print(f"protokoll: {pfad.name}, {len(zeilen)} Zeilen") - arten = Counter(str(z.get("art")) for z in zeilen) - print("zeilen je art:", dict(arten)) - if zeilen: - print("schluessel der ersten zeile:", sorted(zeilen[0])) - takte = [z for z in zeilen if z.get("art") == "takt"] - if takte: - print("schluessel einer taktzeile:", sorted(takte[-1])) - print("phasen:", dict(Counter(str(z.get("phase")) for z in takte))) - print("killswitch:", dict(Counter(str(z.get("killswitch")) for z in takte))) - letzter = takte[-1] - print("letzter takt:", {k: letzter[k] for k in ZEIGEN if k in letzter}) - for z in zeilen: - if z.get("art") in ("order", "ende", "stoerung", "abbruch", "grenze"): - print(z.get("art"), {k: z[k] for k in ZEIGEN + ("seite", "menge", "status") if k in z}) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.vault-tools/trading-plattform-pruefung/s1_alpaca_lesen.py b/.vault-tools/trading-plattform-pruefung/s1_alpaca_lesen.py deleted file mode 100644 index 567a194..0000000 --- a/.vault-tools/trading-plattform-pruefung/s1_alpaca_lesen.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Liest mit dem Alpaca-Klienten eines Code-Baums die echten Antworten des Paper-Kontos -- NUR LESEND, ohne Betraege auszugeben. - -Koordinator-Pruefcode zu Paket S1 (strengerer Parser in trading/broker/alpaca.py). Frage: Liest der neue Parser -jede echte Antwort, die der alte las -- Konto, Positionen, offene Orders, die Orderhistorie (gefuellt, storniert), -Marktuhr, letzter Kurs? Ein Parser, der eine echte Order als unlesbar wertet, waere am Handelstag eine Stoerung. -Aufruf (WSL): python s1_alpaca_lesen.py -""" - -import collections -import dataclasses -import os -import sys -from pathlib import Path - -CODE = Path(sys.argv[1]).resolve() -ECHT = Path(sys.argv[2]).resolve() -sys.path.insert(0, str(CODE)) -os.chdir(ECHT) - -from trading.broker.alpaca import AlpacaBroker # noqa: E402 -from trading.risk.limits import lade_limits # noqa: E402 - -limits = lade_limits("config/risk.toml") -broker = AlpacaBroker.from_secrets(None, universum=limits.universum, feed="iex", timeout=10.0) - - -def form(objekt) -> str: - """Feldnamen und Typen eines Dataclass-Objekts, nie die Werte.""" - if dataclasses.is_dataclass(objekt): - return ", ".join(f"{f.name}:{type(getattr(objekt, f.name)).__name__}" for f in dataclasses.fields(objekt)) - return type(objekt).__name__ - - -schritte = [ - ("konto", lambda: broker.konto()), - ("positionen", lambda: broker.positionen()), - ("offene_orders", lambda: broker.offene_orders()), - ("alle_orders(200)", lambda: broker.alle_orders(limit=200)), - ("marktuhr", lambda: broker.marktuhr()), - ("letzter_kurs", lambda: broker.letzter_kurs("AAPL.XNAS")), - ("verbindung_ok", lambda: broker.verbindung_ok()), -] -befunde = 0 -for name, aufruf in schritte: - try: - ergebnis = aufruf() - except Exception as ausnahme: # noqa: BLE001 - befunde += 1 - # Der Text eines BrokerFehler kann Antwortteile tragen; nur Typ und die ersten Zeichen ohne Ziffernfolgen. - print(f"{name}: AUSNAHME {type(ausnahme).__name__}: {str(ausnahme)[:160]}") - continue - if isinstance(ergebnis, list): - print(f"{name}: {len(ergebnis)} Eintraege" + (f"; Form: {form(ergebnis[0])}" if ergebnis else "")) - if ergebnis and hasattr(ergebnis[0], "status"): - print(" Status:", dict(collections.Counter(getattr(e, "status", "?") for e in ergebnis))) - leer = collections.Counter() - for eintrag in ergebnis: - for feld in dataclasses.fields(eintrag): - if getattr(eintrag, feld.name) is None: - leer[feld.name] += 1 - print(" Felder mit None:", dict(leer)) - else: - print(f"{name}: {form(ergebnis)}") -print(f"{befunde} Aufruf(e) mit Ausnahme.") -sys.exit(2 if befunde else 0) diff --git a/.vault-tools/trading-plattform-pruefung/s5_echtlauf.py b/.vault-tools/trading-plattform-pruefung/s5_echtlauf.py deleted file mode 100644 index 3e40bff..0000000 --- a/.vault-tools/trading-plattform-pruefung/s5_echtlauf.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Gegenmessung zu Paket S5 gegen eine laufende Probe-Instanz: dieselben Angriffe, die der Sicherheitspruefer gemessen hat. - -Koordinator-Pruefcode (WSL). Gemessen wird gegen 127.0.0.1:; die Instanz laeuft aus einer Kopie mit -Broker-Attrappe, Wegwerf-Register und Wegwerf-Zugang. Nichts davon beruehrt den Betrieb. -Aufruf: python s5_echtlauf.py -""" - -import base64 -import hashlib -import hmac -import http.client -import json -import os -import struct -import subprocess -import sys -import threading -import time -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path - -import requests - -PORT = int(sys.argv[1]) -ABLAGE = Path(sys.argv[2]) -CODE = Path(sys.argv[3]) -BASIS = f"http://127.0.0.1:{PORT}" -KOPF = {"Origin": BASIS} -z = json.loads((ABLAGE / "zugang.json").read_text(encoding="utf-8")) -BEN, PW, GEH = z["benutzer"], z["passwort"], z["geheimnis"] -befunde = [] - - -def code(versatz=0): - s = base64.b32decode(GEH) - mac = hmac.new(s, struct.pack(">Q", int(time.time()) // 30 + versatz), hashlib.sha1).digest() - v = mac[-1] & 0x0F - return f"{(struct.unpack('>I', mac[v:v + 4])[0] & 0x7FFFFFFF) % 1_000_000:06d}" - - -def melde_an(): - s = requests.Session() - s.trust_env = False - a = s.post(f"{BASIS}/api/anmeldung", json={"benutzer": BEN, "passwort": PW, "code": code()}, headers=KOPF, timeout=60) - return s, a.status_code - - -def naechster_schritt(): - time.sleep(30.5 - (time.time() % 30)) - - -def server_pid(): - aus = subprocess.run(["pgrep", "-f", "start_probe_w.py"], capture_output=True, text=True).stdout.split() - return int(aus[0]) if aus else None - - -def speicher_mib(pid): - werte = {} - for zeile in Path(f"/proc/{pid}/status").read_text().splitlines(): - if zeile.startswith(("VmRSS", "VmHWM")): - name, wert = zeile.split(":") - werte[name] = int(wert.split()[0]) / 1024 - return werte - - -def pruefe(text, ok): - print((" OK " if ok else " BEFUND ") + text) - if not ok: - befunde.append(text) - - -pid = server_pid() -print(f"Server-PID {pid}, Speicher zu Beginn {speicher_mib(pid)}") - -print("== 1: 422 spiegelt die Eingabe nicht mehr") -lang = "P" * 300 -a = requests.post(f"{BASIS}/api/anmeldung", json={"benutzer": BEN, "passwort": lang, "code": "123456"}, headers=KOPF, timeout=10) -pruefe(f"HTTP {a.status_code}, Passwort im Koerper: {lang in a.text}", a.status_code == 422 and lang not in a.text) -print(f" Koerper: {a.text[:200]}") - -print("== 2: Rumpf 512 MiB mit content-length, unangemeldet") -vor = speicher_mib(pid) -t0 = time.perf_counter() -verbindung = http.client.HTTPConnection("127.0.0.1", PORT, timeout=60) -verbindung.putrequest("POST", "/api/anmeldung") -verbindung.putheader("Origin", BASIS) -verbindung.putheader("Content-Type", "application/json") -verbindung.putheader("Content-Length", str(512 * 1024 * 1024)) -verbindung.endheaders() -gesendet = 0 -status = None -try: - block = b"x" * (1024 * 1024) - for _ in range(512): - verbindung.send(block) - gesendet += len(block) -except OSError as ausnahme: - print(f" Senden brach nach {gesendet / 1024 / 1024:.0f} MiB ab: {type(ausnahme).__name__}") -try: - antwort = verbindung.getresponse() - status = antwort.status - antwort.read() -except Exception as ausnahme: # noqa: BLE001 - print(f" Antwort nicht lesbar: {type(ausnahme).__name__}") -verbindung.close() -nach = speicher_mib(pid) -print(f" Status {status}, {time.perf_counter() - t0:.2f} s, gesendet {gesendet / 1024 / 1024:.0f} MiB, Speicher {vor} -> {nach}") -pruefe("413 und Spitzenspeicher waechst um weniger als 50 MiB", status == 413 and nach["VmHWM"] - vor["VmHWM"] < 50) - -print("== 3: Rumpf ohne content-length (chunked), unangemeldet") -vor = speicher_mib(pid) -t0 = time.perf_counter() -verbindung = http.client.HTTPConnection("127.0.0.1", PORT, timeout=60) -verbindung.putrequest("POST", "/api/anmeldung") -verbindung.putheader("Origin", BASIS) -verbindung.putheader("Content-Type", "application/json") -verbindung.putheader("Transfer-Encoding", "chunked") -verbindung.endheaders() -gesendet = 0 -status = None -try: - block = b"y" * (256 * 1024) - for _ in range(800): - verbindung.send(hex(len(block))[2:].encode() + b"\r\n" + block + b"\r\n") - gesendet += len(block) - verbindung.send(b"0\r\n\r\n") -except OSError as ausnahme: - print(f" Senden brach nach {gesendet / 1024 / 1024:.1f} MiB ab: {type(ausnahme).__name__}") -try: - antwort = verbindung.getresponse() - status = antwort.status - antwort.read() -except Exception as ausnahme: # noqa: BLE001 - print(f" Antwort nicht lesbar: {type(ausnahme).__name__}") -verbindung.close() -nach = speicher_mib(pid) -print(f" Status {status}, {time.perf_counter() - t0:.2f} s, gesendet {gesendet / 1024 / 1024:.1f} MiB, Speicher {vor} -> {nach}") -pruefe("413 (oder Abbruch der Verbindung) und Spitzenspeicher waechst um weniger als 50 MiB", - status in (413, None) and nach["VmHWM"] - vor["VmHWM"] < 50) - -print("== 4: Killswitch unter 40 parallelen teuren Kerzenabrufen") -sitz, st = melde_an() -print(f" Anmeldung HTTP {st}") -cookie = "; ".join(f"{c.name}={c.value}" for c in sitz.cookies) -ds = sitz.get(f"{BASIS}/api/markt/datensaetze", timeout=120).json().get("datensaetze", []) -intraday = [d for d in ds if d.get("intraday")] -if not intraday: - print(" kein Intraday-Datensatz im Katalog -- Lastmessung nicht moeglich") - befunde.append("Lastmessung nicht moeglich") -else: - bt = intraday[0]["bar_type"] - teuer = f"{BASIS}/api/markt/kerzen?bar_type={bt}&aufloesung_min=60&anzahl=3000" - t0 = time.perf_counter() - einzel = requests.get(teuer, headers={"Cookie": cookie}, timeout=600) - print(f" ein einzelner teurer Abruf: HTTP {einzel.status_code}, {time.perf_counter() - t0:.1f} s") - t0 = time.perf_counter() - sitz.post(f"{BASIS}/api/killswitch/anhalten", json={"grund": "grundlinie"}, headers=KOPF, timeout=60) - print(f" Grundlinie POST /api/killswitch/anhalten ohne Last: {time.perf_counter() - t0:.2f} s") - laeuft = [True] - messung = {"gesund": [], "anhalten": []} - - def miss(): - while laeuft[0]: - t = time.perf_counter() - try: - requests.get(f"{BASIS}/api/gesund", timeout=300) - messung["gesund"].append(time.perf_counter() - t) - except Exception: # noqa: BLE001 - messung["gesund"].append(-1.0) - t = time.perf_counter() - try: - r = sitz.post(f"{BASIS}/api/killswitch/anhalten", json={"grund": "lastprobe"}, headers=KOPF, timeout=300) - messung["anhalten"].append((time.perf_counter() - t, r.status_code)) - except Exception: # noqa: BLE001 - messung["anhalten"].append((-1.0, 0)) - time.sleep(0.5) - - faden = threading.Thread(target=miss, daemon=True) - faden.start() - t0 = time.perf_counter() - with ThreadPoolExecutor(max_workers=40) as pool: - codes = list(pool.map(lambda _: requests.get(teuer, headers={"Cookie": cookie}, timeout=900).status_code, range(40))) - dauer = time.perf_counter() - t0 - laeuft[0] = False - faden.join(timeout=310) - zeiten = [w for w, _ in messung["anhalten"]] - print(f" 40 Kerzenabrufe: {dauer:.1f} s, Status {sorted(set(codes))}") - print(f" anhalten waehrend der Last: n={len(zeiten)}, max {max(zeiten):.2f} s, Status {sorted({s for _, s in messung['anhalten']})}") - print(f" gesund waehrend der Last: n={len(messung['gesund'])}, max {max(messung['gesund']):.2f} s") - pruefe("Killswitch antwortet unter Last in unter 5 s (vorher gemessen: 118 s)", 0 <= max(zeiten) < 5.0) - -print("== 5: ueberlanger Grund landet nicht im Register") -db = ABLAGE / "registry.db" -vorher = db.stat().st_size -a = sitz.post(f"{BASIS}/api/killswitch/anhalten", json={"grund": "G" * (1024 * 1024)}, headers=KOPF, timeout=120) -print(f" grund mit 1 MiB -> HTTP {a.status_code}, registry.db {vorher} -> {db.stat().st_size} Bytes") -pruefe("abgelehnt (413 oder 422), Register waechst nicht", a.status_code in (413, 422) and db.stat().st_size - vorher < 65536) - -print("== 6: Passwortwechsel beendet die Sitzung") -pruefe(f"Sitzung gilt vor dem Wechsel (HTTP {sitz.get(f'{BASIS}/api/uebersicht', timeout=60).status_code})", True) -sys.path.insert(0, str(CODE)) -os.chdir(CODE) -from trading.dashboard.zugang import Benutzer, lies_benutzer, schreibe_benutzer, setze_passwort # noqa: E402 - -bestand = lies_benutzer(ABLAGE / "zugang.toml") -bestand[BEN] = Benutzer(name=BEN, passwort=setze_passwort("ein-neues-passwort-2026"), totp_geheimnis=bestand[BEN].totp_geheimnis) -schreibe_benutzer(ABLAGE / "zugang.toml", bestand) -time.sleep(1.0) -danach = sitz.get(f"{BASIS}/api/uebersicht", timeout=60).status_code -pruefe(f"alte Sitzung nach dem Passwortwechsel: HTTP {danach}", danach == 401) -halt = sitz.post(f"{BASIS}/api/killswitch/anhalten", json={"grund": "nach-wechsel"}, headers=KOPF, timeout=60).status_code -pruefe(f"Killswitch mit der alten Sitzung: HTTP {halt}", halt == 401) - -print(f"Speicher am Ende {speicher_mib(pid)}") -print(f"{len(befunde)} Befund(e): {befunde}") -sys.exit(2 if befunde else 0) diff --git a/.vault-tools/trading-plattform-pruefung/scan_push.sh b/.vault-tools/trading-plattform-pruefung/scan_push.sh deleted file mode 100644 index 4a261b1..0000000 --- a/.vault-tools/trading-plattform-pruefung/scan_push.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash -# Geheimnis-Scan vor dem Push, mit Regressionsnachweis. Aufruf: bash scan_push.sh [ ...] -R="/mnt/d/Claude/Code Projekte/active - Trading Plattform" -W="$(cd "$(dirname "$0")" && pwd)" -PY=/home/claude/venvs/trading/bin/python -von="$1"; shift -echo "Verlauf: $(git -C "$R" log --oneline "$von".."$1" | wc -l) Commits in $von..$1" -echo "== Probe (muss Treffer melden):" -SCAN_PROBE="${SCAN_PROBE_TEXT:-Nachholtage melden}" "$PY" "$W/geheimnis_scan.py" "$R" "$von" "$@" 2>&1 | tail -3 -echo "== Scan:" -"$PY" "$W/geheimnis_scan.py" "$R" "$von" "$@" 2>&1 | tail -6 diff --git a/.vault-tools/trading-plattform-pruefung/skripte_probe.sh b/.vault-tools/trading-plattform-pruefung/skripte_probe.sh deleted file mode 100644 index 1ba2cbb..0000000 --- a/.vault-tools/trading-plattform-pruefung/skripte_probe.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash -# Probe der zwei Skripte mit argparse -- ausschliesslich aus einem leeren Temp-Verzeichnis. -# Die Uebung selbst (--ausfuehren) wird NICHT gefahren. Aufruf: wsl -d Debian -- bash -s < skripte_probe.sh -set -u -P="/mnt/d/Claude/Code Projekte/active - Trading Plattform" -PY="$HOME/venvs/trading/bin/python" - -# Vorbedingung: Es muessen die neuen Fassungen sein. Die alte Uebung ignoriert jedes Argument und handelt. -if ! grep -q "if not parse_args().ausfuehren" "$P/scripts/killswitch_uebung.py"; then echo "ABBRUCH: killswitch_uebung.py prueft den Schalter nicht"; exit 9; fi -if ! grep -q "argparse" "$P/scripts/dashboard.py"; then echo "ABBRUCH: dashboard.py ohne argparse"; exit 9; fi - -T=$(mktemp -d) -cd "$T" || exit 9 -echo "Arbeitsverzeichnis: $T ($(ls -A "$T" | wc -l) Eintraege)" -VORHER=$(ls -la --time-style=full-iso "$P/data/registry.db" 2>/dev/null | md5sum) - -lauf() { - name="$1"; shift - "$PY" "$@" > "$T/aus.txt" 2> "$T/err.txt" < /dev/null - code=$? - echo "=== $name: exit=$code" - echo "--- stdout (erste Zeilen):"; head -4 "$T/aus.txt" - echo "--- stderr (ohne Warnungen, letzte Zeilen):"; grep -v -E "Pandas4Warning|utcnow|warnings.warn" "$T/err.txt" | tail -3 - rm -f "$T/aus.txt" "$T/err.txt" -} - -lauf "dashboard.py --help" "$P/scripts/dashboard.py" --help -lauf "killswitch_uebung.py --help" "$P/scripts/killswitch_uebung.py" --help -lauf "killswitch_uebung.py ohne Schalter" "$P/scripts/killswitch_uebung.py" -lauf "killswitch_uebung.py --unbekannt" "$P/scripts/killswitch_uebung.py" --unbekannt -lauf "dashboard.py ohne Konfiguration im Verzeichnis" "$P/scripts/dashboard.py" - -NACHHER=$(ls -la --time-style=full-iso "$P/data/registry.db" 2>/dev/null | md5sum) -echo "Registerordner unveraendert: $([ "$VORHER" = "$NACHHER" ] && echo ja || echo NEIN)" -echo "Rest im Temp-Verzeichnis: $(ls -A "$T" | wc -l) Eintraege" -rmdir "$T" 2>/dev/null diff --git a/.vault-tools/trading-plattform-pruefung/start_probe.sh b/.vault-tools/trading-plattform-pruefung/start_probe.sh deleted file mode 100644 index c25027e..0000000 --- a/.vault-tools/trading-plattform-pruefung/start_probe.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -# Probe-Backend im Vordergrund (fuer run_in_background des Werkzeugs). Aufruf: bash -s < start_probe.sh -set -u -SCRATCH="/mnt/d/Claude/.vault-tools/trading-plattform-pruefung" -cd "/mnt/d/Claude/Code Projekte/active - Trading Plattform" || exit 9 -PYTHONPATH=. exec "$HOME/venvs/trading/bin/python" "$SCRATCH/start_probe_backend.py" "$1" "$2" < /dev/null diff --git a/.vault-tools/trading-plattform-pruefung/start_probe_backend.py b/.vault-tools/trading-plattform-pruefung/start_probe_backend.py deleted file mode 100644 index af242ae..0000000 --- a/.vault-tools/trading-plattform-pruefung/start_probe_backend.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Probe-Backend des Koordinators: die echte App mit Attrappe und Wegwerf-Register auf eigenem Port. - -Kein Broker-Aufruf, kein Ereignis im echten Register. Aufruf aus dem Projektordner: - python start_probe_backend.py [sendeintervall_s] -""" - -import dataclasses -import sys -import tempfile -from pathlib import Path - -import uvicorn - -from trading.broker.attrappe import BrokerAttrappe -from trading.dashboard.app import erzeuge_app -from trading.dashboard.config import lade_dashboard_config -from trading.killswitch.killswitch import lade_killswitch_config -from trading.registry.registry import Registry - -port = int(sys.argv[1]) -config = lade_dashboard_config("config/dashboard.toml") -aenderungen = {"port": port, "broker_wahl": "attrappe"} -if len(sys.argv) > 2: - aenderungen["sendeintervall_s"] = float(sys.argv[2]) -config = dataclasses.replace(config, **aenderungen) -temp = Path(tempfile.mkdtemp(prefix="probe-backend-")) -app = erzeuge_app( - broker=BrokerAttrappe(), - registry=Registry(db_pfad=temp / "registry.db", artefakt_wurzel=temp / "artifacts"), - config=config, - killswitch_config=lade_killswitch_config("config/killswitch.toml"), - dist=Path("dashboard/dist"), -) -print(f"Probe-Backend auf 127.0.0.1:{port}, Takt {config.sendeintervall_s} s, Register {temp}", flush=True) -uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning", proxy_headers=False) diff --git a/.vault-tools/trading-plattform-pruefung/start_probe_w.py b/.vault-tools/trading-plattform-pruefung/start_probe_w.py deleted file mode 100644 index 78c5a9d..0000000 --- a/.vault-tools/trading-plattform-pruefung/start_probe_w.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Probe-Backend fuer den Browserlauf zu Paket W: die echte App mit Broker-Attrappe, Wegwerf-Register und Wegwerf-Zugang. - -Koordinator-Pruefcode, nicht fuers Repo. Wie .vault-tools/trading-plattform-pruefung/start_probe_zugang.py, -dazu die Konfiguration fuer Sicherheitsschluessel: RP-ID localhost, Origin http://localhost:. -Gestartet wird aus einer KOPIE des Baums (dort gibt es keine Geheimnisdatei) -- kein Broker-Aufruf, kein -Ereignis im echten Register, keine echte Zugangsdatei, kein Melder. - -Aufruf (WSL, aus der Kopie): python start_probe_w.py -""" - -import dataclasses -import json -import os -import sys -from pathlib import Path - -sys.path.insert(0, str(Path.cwd())) - -import uvicorn # noqa: E402 - -from trading.broker.attrappe import BrokerAttrappe # noqa: E402 -from trading.dashboard.app import erzeuge_app # noqa: E402 -from trading.dashboard.config import lade_dashboard_config # noqa: E402 -from trading.dashboard.zugang import Benutzer # noqa: E402 -from trading.dashboard.zugang import schreibe_benutzer # noqa: E402 -from trading.dashboard.zugang import setze_passwort # noqa: E402 -from trading.killswitch.killswitch import lade_killswitch_config # noqa: E402 -from trading.registry.registry import Registry # noqa: E402 - -BENUTZER = "probe" -PASSWORT = "nur-fuer-die-probe-2026" -GEHEIMNIS = "JBSWY3DPEHPK3PXP" - -port = int(sys.argv[1]) -ordner = Path(sys.argv[2]) -# Optional: ein Marktkatalog, der nur gelesen wird (fuer die Lastmessung der Kerzenabrufe). -katalog = sys.argv[3] if len(sys.argv) > 3 else None -ordner.mkdir(parents=True, exist_ok=True) -# Seit Paket E2 (2026-09-22) findet die Plattform die Geheimnisdatei auch ueber TRADING_GEHEIMNISSE -# bzw. CREDENTIALS_DIRECTORY -- nach dem Umzug steht die erste in jeder Login-Shell. Die Probe nimmt -# beide aus ihrer Umgebung, bevor die App gebaut wird: Sonst baute erzeuge_app den echten -# Stille-Waechter und Hausmelder, obwohl im Baum keine config/secrets.toml liegt (Befund der -# Abschlusspruefung E2). -for name in ("TRADING_GEHEIMNISSE", "CREDENTIALS_DIRECTORY"): - os.environ.pop(name, None) -assert not Path("config/secrets.toml").exists(), "Die Probe startet nur aus einem Baum ohne Geheimnisdatei" - -zugang_datei = ordner / "zugang.toml" -schreibe_benutzer( - zugang_datei, - {BENUTZER: Benutzer(name=BENUTZER, passwort=setze_passwort(PASSWORT), totp_geheimnis=GEHEIMNIS)}, -) -(ordner / "zugang.json").write_text( - json.dumps({"benutzer": BENUTZER, "passwort": PASSWORT, "geheimnis": GEHEIMNIS}), encoding="utf-8" -) -(ordner / "paper").mkdir(exist_ok=True) - -config = dataclasses.replace( - lade_dashboard_config("config/dashboard.toml"), - port=port, - broker_wahl="attrappe", - sendeintervall_s=2.0, - erlaubte_hosts=(f"127.0.0.1:{port}", f"localhost:{port}"), - anmeldung_pflicht=True, - mfa_pflicht=True, - zugang_datei=str(zugang_datei), - zugang_protokoll=str(ordner / "anmeldungen.jsonl"), - paper_status_datei=str(ordner / "status.json"), - paper_protokoll_ordner=str(ordner / "paper"), - schluessel_rp_id="localhost", - **({} if katalog is None else {"markt_katalog": katalog}), - schluessel_origins=(f"http://localhost:{port}",), -) -app = erzeuge_app( - broker=BrokerAttrappe(), - registry=Registry(db_pfad=ordner / "registry.db", artefakt_wurzel=ordner / "artifacts"), - config=config, - killswitch_config=lade_killswitch_config("config/killswitch.toml"), - dist=Path("dashboard/dist"), -) -print(f"Probe-Backend W auf 127.0.0.1:{port}, Ablage {ordner}", flush=True) -uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning", server_header=False, proxy_headers=False) diff --git a/.vault-tools/trading-plattform-pruefung/start_probe_zugang.py b/.vault-tools/trading-plattform-pruefung/start_probe_zugang.py deleted file mode 100644 index c7495a0..0000000 --- a/.vault-tools/trading-plattform-pruefung/start_probe_zugang.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Probe-Backend mit Anmeldung: die echte App mit Attrappe, Wegwerf-Register und Wegwerf-Zugang. - -Koordinator-Werkzeug zu Teil 10, nicht fuers Repo. Kein Broker-Aufruf, kein Ereignis im echten -Register, keine echte Zugangsdatei. Der Wegwerf-Zugang traegt ein bekanntes Passwort und das -Beispielgeheimnis der TOTP-Literatur; beides steht in zugang.json im Ausgabeordner, damit -angriffe.py und der Browser-Lauf sich anmelden koennen. - -Aufruf aus dem Projektordner (oder einer Kopie davon): - python start_probe_zugang.py [mit-paperstatus] [zwei-benutzer] - -Mit zwei-benutzer kommt ein zweiter Wegwerf-Benutzer "zweiter" dazu (eigenes TOTP-Geheimnis, in zugang2.json), -damit sich pruefen laesst, dass das Entfernen des einen den anderen nicht trifft. -""" - -import dataclasses -import json -import os -import sys -from datetime import UTC -from datetime import datetime -from pathlib import Path - -import uvicorn - -from trading.broker.attrappe import BrokerAttrappe -from trading.dashboard.app import erzeuge_app -from trading.dashboard.config import lade_dashboard_config -from trading.dashboard.zugang import Benutzer -from trading.dashboard.zugang import schreibe_benutzer -from trading.dashboard.zugang import setze_passwort -from trading.killswitch.killswitch import lade_killswitch_config -from trading.registry.registry import Registry - -BENUTZER = "probe" -PASSWORT = "nur-fuer-die-probe-2026" -GEHEIMNIS = "JBSWY3DPEHPK3PXP" - -port = int(sys.argv[1]) -ordner = Path(sys.argv[2]) -# Befund der Sicherheitspruefung vom 2026-09-20: erzeuge_app startete (bis Paket S5) immer den echten -# Stille-Waechter und las dafuer config/paper.toml und config/secrets.toml aus dem Arbeitsordner. Eine -# Probe-Instanz im echten Projektordner haette nach 180 s auf das Telefon geklingelt. Deshalb nur aus -# einer Kopie oder einem Arbeitsbaum ohne Geheimnisdatei. -# Seit Paket E2 (2026-09-22) findet die Plattform die Geheimnisdatei auch ueber TRADING_GEHEIMNISSE -# bzw. CREDENTIALS_DIRECTORY -- nach dem Umzug steht die erste in jeder Login-Shell, und die Wache -# unten liefe ins Leere. Die Probe nimmt beide aus ihrer Umgebung, bevor die App gebaut wird -# (Befund der Abschlusspruefung E2). -for name in ("TRADING_GEHEIMNISSE", "CREDENTIALS_DIRECTORY"): - os.environ.pop(name, None) -if Path("config/secrets.toml").exists(): - sys.exit( - "Abbruch: Im Arbeitsordner liegt config/secrets.toml. Die Probe startet nur aus einer Kopie " - "ohne Geheimnisdatei, sonst spraeche sie mit dem echten ntfy und dem echten Home Assistant." - ) -ordner.mkdir(parents=True, exist_ok=True) -mit_status = "mit-paperstatus" in sys.argv[3:] -zwei_benutzer = "zwei-benutzer" in sys.argv[3:] -ZWEITER = "zweiter" -GEHEIMNIS_ZWEITER = "KRUGS4ZANFZSAYJA" - -zugang_datei = ordner / "zugang.toml" -benutzer = {BENUTZER: Benutzer(name=BENUTZER, passwort=setze_passwort(PASSWORT), totp_geheimnis=GEHEIMNIS)} -if zwei_benutzer: - benutzer[ZWEITER] = Benutzer(name=ZWEITER, passwort=setze_passwort(PASSWORT), totp_geheimnis=GEHEIMNIS_ZWEITER) - (ordner / "zugang2.json").write_text( - json.dumps({"benutzer": ZWEITER, "passwort": PASSWORT, "geheimnis": GEHEIMNIS_ZWEITER}), - encoding="utf-8", - ) -schreibe_benutzer(zugang_datei, benutzer) -(ordner / "zugang.json").write_text( - json.dumps({"benutzer": BENUTZER, "passwort": PASSWORT, "geheimnis": GEHEIMNIS}), - encoding="utf-8", -) - -paper_ordner = ordner / "paper" -paper_ordner.mkdir(exist_ok=True) -status_datei = ordner / "status.json" -if mit_status: - protokoll = paper_ordner / "2026-09-18T131502-paper.jsonl" - zeilen = [ - {"art": "kopf", "modus": "paper"}, - {"art": "takt", "schritt": 1, "phase": "handelt", "position": 10, "zielposition": 10}, - ] - protokoll.write_text("\n".join(json.dumps(z) for z in zeilen) + "\n", encoding="utf-8") - status_datei.write_text( - json.dumps( - { - "zeit": datetime.now(UTC).isoformat(), - "modus": "paper", - "phase": "handelt", - "agent": { - "quelle": "register", - "name": "aapl-minute-ppo", - "version": "v20260907-074410", - "status": "paper", - }, - "instrument": "AAPL.XNAS", - "killswitch": "aktiv", - "position": 10, - "zielposition": 10, - "letzte_aktion": {"zeit": datetime.now(UTC).isoformat(), "aktion": 3, "stufe": 1}, - "letzte_kerze": datetime.now(UTC).replace(second=0, microsecond=0).isoformat(), - "orders": 1, - "kontowert": 100012.5, - "startkapital": 100000.0, - "hinweis": None, - "protokoll": str(protokoll), - "pid": 1, - } - ), - encoding="utf-8", - ) - -config = dataclasses.replace( - lade_dashboard_config("config/dashboard.toml"), - port=port, - broker_wahl="attrappe", - sendeintervall_s=2.0, - erlaubte_hosts=(f"127.0.0.1:{port}", f"localhost:{port}"), - anmeldung_pflicht=True, - mfa_pflicht=True, - zugang_datei=str(zugang_datei), - zugang_protokoll=str(ordner / "anmeldungen.jsonl"), - paper_status_datei=str(status_datei), - paper_protokoll_ordner=str(paper_ordner), -) -app = erzeuge_app( - broker=BrokerAttrappe(), - registry=Registry(db_pfad=ordner / "registry.db", artefakt_wurzel=ordner / "artifacts"), - config=config, - killswitch_config=lade_killswitch_config("config/killswitch.toml"), - dist=Path("dashboard/dist"), -) -print(f"Probe-Backend mit Anmeldung auf 127.0.0.1:{port}, Ablage {ordner}", flush=True) -uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning", server_header=False, proxy_headers=False) diff --git a/.vault-tools/trading-plattform-pruefung/vorstart_pruefung.py b/.vault-tools/trading-plattform-pruefung/vorstart_pruefung.py deleted file mode 100644 index 8c0cc0c..0000000 --- a/.vault-tools/trading-plattform-pruefung/vorstart_pruefung.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Vorpruefung vor dem Start des Paper-Runners: liest nur, startet nichts, aendert nichts. - -Koordinator-Werkzeug zu Teil 10, nicht fuers Repo. Fragen und Herkunft der Erwartung: - 1. Laeuft schon ein Runner? Projektvorgabe (Doppelstart endet mit Exit 2; hier vorher sichtbar). - 2. Wie steht der Killswitch? Nur Auskunft: Der Runner-Start stellt nach der Neustartregel immer - auf angehalten. Ein Befund ist allein 'gesperrt'. Gelesen aus - data/registry.db, nur lesend. - 3. Ist das Paper-Konto glatt? Projektvorgabe (der Tag beginnt ohne Position und ohne offene Order). - 4. Was sagt die Marktuhr des Brokers? Quelle: GET /v2/clock (is_open, next_open, next_close). - 5. Wie weit liegt die eigene Uhr daneben? Quelle: Date-Kopf der Broker-Antwort gegen die eigene Uhr; - mehr als 30 Sekunden waeren fuer TOTP und Minutentakt ein Problem. -Was es nicht sieht: ob das Dashboard laeuft (das fragt der Koordinator unter Windows ab), ob der -IEX-Feed frisch liefert (dafuer handabruf_kerzen.py), und den Code-Stand (dafuer git im Projektordner). -Eine fehlende Voraussetzung ist ein Befund (Exit 3), jede Abweichung ein Befund (Exit 2). - -Liest die Geheimnisdatei ueber den Aufloeser des Projekts (TRADING_GEHEIMNISSE, sonst config/secrets.toml) und gibt die Schluessel nie aus. -Aufruf aus dem Projektordner (WSL, Venv): python vorstart_pruefung.py -""" - -import sqlite3 -import subprocess -import sys -import tomllib -from datetime import UTC, datetime -from email.utils import parsedate_to_datetime -from pathlib import Path - -import requests - -sys.path.insert(0, str(Path.cwd())) -from trading import geheimnisse # noqa: E402 -- aus dem Projektordner, wie oben verlangt - -HANDEL_URL = "https://paper-api.alpaca.markets" - - -def main() -> int: - befunde: list[str] = [] - - laeufer = subprocess.run(["pgrep", "-af", "scripts/paper.py"], capture_output=True, text=True).stdout.strip() - eigene = [z for z in laeufer.splitlines() if "pgrep" not in z] - print(f"1. laufende runner: {len(eigene)}") - for zeile in eigene: - print(" ", zeile[:140]) - if eigene: - befunde.append("es laeuft schon ein Runner") - - db = Path("data/registry.db") - if not db.is_file(): - print("VORAUSSETZUNG FEHLT: data/registry.db (aus dem Projektordner starten)") - return 3 - verbindung = sqlite3.connect(f"file:{db.as_posix()}?mode=ro", uri=True) - try: - zeile = verbindung.execute("select zustand, seit, ausloeser from killswitch_zustand").fetchone() - finally: - verbindung.close() - # Kein Befund, gleich wie er steht: Der Runner-Start stellt nach der Neustartregel immer auf - # angehalten (Registerereignis 'neustart'). Ein 'aktiv' VOR dem Start heisst nur, dass ein - # frueheres Fortsetzen ueberschrieben wird -- der Klick muss NACH dem Start kommen. - print(f"2. killswitch im register: {zeile}") - if zeile and zeile[0] == "aktiv": - print(" HINWEIS: steht auf aktiv; der Runner-Start ueberschreibt das mit angehalten, Fortsetzen erst danach") - if zeile and zeile[0] == "gesperrt": - befunde.append("Killswitch ist gesperrt; Fortsetzen allein genuegt dann nicht (Freigeben im Dashboard)") - - secrets = geheimnisse.pfad() - if not secrets.is_file(): - print(f"VORAUSSETZUNG FEHLT: {geheimnisse.beschreibung()}") - return 3 - werte = tomllib.loads(secrets.read_text(encoding="utf-8"))["alpaca"] - kopf = {"APCA-API-KEY-ID": werte["key_id"], "APCA-API-SECRET-KEY": werte["secret_key"]} - - positionen = requests.get(f"{HANDEL_URL}/v2/positions", headers=kopf, timeout=20) - offene = requests.get(f"{HANDEL_URL}/v2/orders", headers=kopf, params={"status": "open", "limit": 100}, timeout=20) - if positionen.status_code != 200 or offene.status_code != 200: - print(f"VORAUSSETZUNG FEHLT: Broker antwortet {positionen.status_code}/{offene.status_code}") - return 3 - print(f"3. paper-konto: {len(positionen.json())} position(en), {len(offene.json())} offene order(s)") - for p in positionen.json(): - print(f" position {p.get('symbol')} qty {p.get('qty')} side {p.get('side')}") - for o in offene.json(): - print(f" order {o.get('side')} {o.get('qty')} {o.get('symbol')} status {o.get('status')}") - if positionen.json() or offene.json(): - befunde.append("das Paper-Konto ist nicht glatt") - - vorher = datetime.now(UTC) - uhr = requests.get(f"{HANDEL_URL}/v2/clock", headers=kopf, timeout=20) - nachher = datetime.now(UTC) - if uhr.status_code != 200: - print(f"VORAUSSETZUNG FEHLT: /v2/clock antwortet {uhr.status_code}") - return 3 - daten = uhr.json() - print(f"4. marktuhr: is_open {daten.get('is_open')}, next_open {daten.get('next_open')}, next_close {daten.get('next_close')}") - - datum = uhr.headers.get("Date") - if datum: - server = parsedate_to_datetime(datum) - mitte = vorher + (nachher - vorher) / 2 - abstand = (mitte - server).total_seconds() - # Der Date-Kopf hat Sekundenaufloesung; unter zwei Sekunden ist deshalb nichts zu sagen. - print(f"5. eigene uhr gegen den Date-Kopf des Brokers: {abstand:+.1f} s (Aufloesung eine Sekunde)") - if abs(abstand) > 30: - befunde.append(f"die eigene Uhr liegt {abstand:+.0f} s neben der des Brokers") - else: - print("5. kein Date-Kopf in der Antwort; Uhrvergleich entfaellt") - - print() - for befund in befunde: - print("BEFUND:", befund) - print(f"{len(befunde)} Befund(e).") - return 2 if befunde else 0 - - -if __name__ == "__main__": - try: - sys.exit(main()) - except Exception as ausnahme: # noqa: BLE001 - ein Absturz des Werkzeugs ist selbst ein Befund - print(f"BEFUND: das Werkzeug ist abgebrochen: {ausnahme!r}") - sys.exit(2) diff --git a/.vault-tools/trading-plattform-pruefung/wiederanlauf_probe.ps1 b/.vault-tools/trading-plattform-pruefung/wiederanlauf_probe.ps1 deleted file mode 100644 index 29c1d4d..0000000 --- a/.vault-tools/trading-plattform-pruefung/wiederanlauf_probe.ps1 +++ /dev/null @@ -1,53 +0,0 @@ -# Wiederanlauf-Probe: beendet die Distribution HART (wsl --terminate) und misst, wann Wachhalter, -# Distribution und Dashboard von selbst zurueck sind. Ein laufender Paper-Runner stirbt dabei mit -- -# deshalb nur ausserhalb der Handelszeit (15:30 bis 22:00 Uhr Berliner Zeit) und nur mit -Ausfuehren. -# Waehrend der Messung keinen anderen wsl.exe-Aufruf absetzen: Jeder Klient weckt die Distribution -# selbst und verfaelscht das Ergebnis. Die Datei ist reines ASCII (PowerShell 5.1 liest ohne BOM ANSI). -# Gemessen am 2026-09-20: terminate 19:30:17, Aufgabe und Distribution Running um 19:35:06, HTTP 200 um 19:35:13. -param( - [string]$Name = "Trading Plattform - WSL wachhalten", - [int]$MaxSekunden = 480, - [switch]$Ausfuehren -) - -if (-not $Ausfuehren) { - "Nichts getan. Diese Probe beendet die WSL-Distribution hart; ein laufender Runner stirbt mit." - "Aufruf ausserhalb der Handelszeit: powershell -File wiederanlauf_probe.ps1 -Ausfuehren" - exit 2 -} - -$wsl = "$env:SystemRoot\System32\wsl.exe" - -function Distro { - $roh = ((& $wsl -l -v) | Out-String).Replace([string][char]0, '') - foreach ($zeile in ($roh -split "\r?\n")) { - if ($zeile -match 'Debian\s+(\w+)') { return $Matches[1] } - } - return "unbekannt" -} - -function Probe { - try { - $r = Invoke-WebRequest -UseBasicParsing -TimeoutSec 3 http://127.0.0.1:8144/api/gesund - return "HTTP " + $r.StatusCode - } catch { - return "tot" - } -} - -"Vorher: Aufgabe " + (Get-ScheduledTask -TaskName $Name).State + ", Distro " + (Distro) + ", Dashboard " + (Probe) -& $wsl --terminate Debian | Out-Null -$start = Get-Date -"terminate um " + $start.ToString("HH:mm:ss") -$letzter = "" -while (((Get-Date) - $start).TotalSeconds -lt $MaxSekunden) { - $a = (Get-ScheduledTask -TaskName $Name).State - $d = Distro - $p = Probe - $s = [int]((Get-Date) - $start).TotalSeconds - $jetzt = "Aufgabe $a / Distro $d / Dashboard $p" - if ($jetzt -ne $letzter) { "+${s}s (" + (Get-Date).ToString("HH:mm:ss") + "): $jetzt"; $letzter = $jetzt } - if ($s -gt 15 -and $a -eq "Running" -and $d -eq "Running" -and $p -eq "HTTP 200") { break } - Start-Sleep -Seconds 5 -} -"Ende der Messung um " + (Get-Date).ToString("HH:mm:ss") diff --git a/.vault-tools/trading-plattform-pruefung/wiederaufbau.py b/.vault-tools/trading-plattform-pruefung/wiederaufbau.py deleted file mode 100644 index 152c04b..0000000 --- a/.vault-tools/trading-plattform-pruefung/wiederaufbau.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Wiederaufbau-Test im Browser: Backend weg, Backend wieder da -- ohne Neuladen der Seite. - -Koordinator-Pruefskript (Windows-Python mit Playwright), nicht fuers Repo. -Frage: Zeigt die Kopfzeile "Backend getrennt", wenn das Backend stirbt, und von selbst wieder -"Backend verbunden", wenn es zurueck ist? Herkunft: Projektvorgabe (Spec, Abschnitt Frontend). -Statustexte aus dashboard/src/components/Kopfzeile.tsx (abgeschrieben am 2026-09-17). -Was es nicht sieht: die Wartezeiten 1/2/5/10 s im Einzelnen (dafuer api.test.ts), andere Browser als Chromium. -Regressionsnachweis: --ohne-neustart laesst das Backend unten; der Lauf muss dann mit Exit 2 am Schritt -"wieder verbunden" enden. -Aufruf: python -X utf8 wiederaufbau.py [--ohne-neustart] -""" - -import subprocess -import sys -import time -import urllib.request -from pathlib import Path - -from playwright.sync_api import TimeoutError as PlaywrightTimeout -from playwright.sync_api import sync_playwright - -PORT = 8146 -BASIS = f"http://localhost:{PORT}" -HIER = Path(__file__).parent - - -def starte_backend() -> subprocess.Popen: - skript = open(HIER / "start_probe.sh", "rb") - return subprocess.Popen( - ["wsl", "-d", "Debian", "--", "bash", "-s", str(PORT), "1.0"], - stdin=skript, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - ) - - -def beende_backend() -> None: - subprocess.run(["wsl", "-d", "Debian", "--", "pkill", "-f", f"start_probe_backend.py {PORT}"], - stdin=subprocess.DEVNULL, capture_output=True) - - -def warte_auf_backend(sekunden: float) -> bool: - ende = time.time() + sekunden - while time.time() < ende: - try: - with urllib.request.urlopen(BASIS + "/api/uebersicht", timeout=1) as antwort: - if antwort.status == 200: - return True - except OSError: - time.sleep(0.5) - return False - - -def main() -> int: - ohne_neustart = "--ohne-neustart" in sys.argv - beende_backend() - prozess = starte_backend() - schritte: list[str] = [] - try: - if not warte_auf_backend(40): - print("BEFUND: Probe-Backend kommt nicht hoch -- der Lauf misst nichts") - return 2 - with sync_playwright() as p: - browser = p.chromium.launch() - seite = browser.new_page(viewport={"width": 1280, "height": 800}) - fehler: list[str] = [] - seite.on("pageerror", lambda e: fehler.append(str(e))) - seite.goto(BASIS + "/", wait_until="domcontentloaded") - seite.evaluate("() => { window.__marke = 'nicht neu geladen'; }") - - def erwarte(text: str, sekunden: float, name: str) -> bool: - start = time.time() - try: - seite.get_by_text(text, exact=True).first.wait_for(timeout=sekunden * 1000) - except PlaywrightTimeout: - print(f"BEFUND: Schritt '{name}': '{text}' erscheint nicht binnen {sekunden} s") - return False - schritte.append(f"{name}: '{text}' nach {time.time() - start:.1f} s") - return True - - if not erwarte("Backend verbunden", 20, "erste Verbindung"): - return 2 - beende_backend() - prozess.wait(timeout=20) - if not erwarte("Backend getrennt", 20, "nach dem Ende des Backends"): - return 2 - if not ohne_neustart: - prozess = starte_backend() - if not warte_auf_backend(40): - print("BEFUND: Probe-Backend kommt nach dem Neustart nicht hoch") - return 2 - if not erwarte("Backend verbunden", 30, "wieder verbunden"): - return 2 - marke = seite.evaluate("() => window.__marke") - if marke != "nicht neu geladen": - print(f"BEFUND: Die Seite wurde neu geladen (Marke {marke!r})") - return 2 - if fehler: - print(f"BEFUND: Seitenfehler: {fehler[:3]}") - return 2 - browser.close() - for schritt in schritte: - print("ok " + schritt) - print("Kein Befund: Wiederaufbau ohne Neuladen.") - return 0 - finally: - for schritt in schritte: - pass - beende_backend() - try: - prozess.wait(timeout=10) - except subprocess.TimeoutExpired: - prozess.kill() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.vault-tools/watermarks-remover/start-service.sh b/.vault-tools/watermarks-remover/start-service.sh deleted file mode 100644 index 781e4ae..0000000 --- a/.vault-tools/watermarks-remover/start-service.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -# start-service.sh — startet den watermarks-remover HTTP-Service (Skill "remove-ai-marks") -# in der Cloud-Workspace einer Cowork-Sitzung. -# -# Hintergrund: Die Cloud-Umgebung ist pro Sitzung neu, daher klont dieses Skript -# das Repo bei Bedarf neu und startet den Service im Hintergrund. Nach dem Lauf -# ist der Service unter http://127.0.0.1:8765 erreichbar (siehe SKILL.md unter -# skills/remove-ai-marks/ für die /inspect- und /clean-Endpunkte). -# -# Aufruf: -# bash start-service.sh -# -# Optional per Umgebungsvariable überschreibbar: -# WATERMARKS_REMOVER_DIR Zielordner fürs Repo (Default: $HOME/work/watermarks-remover) -# WATERMARKS_REMOVER_HOST Bind-Host (Default: 127.0.0.1) -# WATERMARKS_REMOVER_PORT Bind-Port (Default: 8765) - -set -euo pipefail - -WORKDIR="${WATERMARKS_REMOVER_DIR:-$HOME/work/watermarks-remover}" -REPO_URL="https://github.com/guillaumemeyer/watermarks-remover.git" -HOST="${WATERMARKS_REMOVER_HOST:-127.0.0.1}" -PORT="${WATERMARKS_REMOVER_PORT:-8765}" -LOG="/tmp/wr-server.log" - -if [ ! -d "$WORKDIR/.git" ]; then - echo "==> Klone watermarks-remover nach $WORKDIR ..." - git clone --depth 1 "$REPO_URL" "$WORKDIR" -else - echo "==> Repo bereits vorhanden unter $WORKDIR, überspringe Klonen." -fi - -cd "$WORKDIR" - -if curl -sf "http://${HOST}:${PORT}/health" >/dev/null 2>&1; then - echo "==> Service läuft bereits auf ${HOST}:${PORT}." -else - echo "==> Starte Service auf ${HOST}:${PORT} (Log: $LOG) ..." - nohup python3 service/scripts/server.py --host "$HOST" --port "$PORT" > "$LOG" 2>&1 & - disown || true - - ok=0 - for _ in $(seq 1 20); do - if curl -sf "http://${HOST}:${PORT}/health" >/dev/null 2>&1; then - ok=1 - break - fi - sleep 0.5 - done - - if [ "$ok" -ne 1 ]; then - echo "!! Service ist nach 10s nicht erreichbar. Log-Ausschnitt:" - tail -n 40 "$LOG" || true - exit 1 - fi - echo "==> Service ist erreichbar." -fi - -echo -echo "--- /health ---" -curl -s "http://${HOST}:${PORT}/health"; echo -echo "--- /capabilities ---" -curl -s "http://${HOST}:${PORT}/capabilities"; echo diff --git a/AI/Regelwerk/veroeffentlichung.md b/AI/Regelwerk/veroeffentlichung.md index f6d87b0..8873786 100644 --- a/AI/Regelwerk/veroeffentlichung.md +++ b/AI/Regelwerk/veroeffentlichung.md @@ -5,7 +5,7 @@ title: "Veröffentlichung der Anweisungsschicht" summary: "Welcher Teil des Vaults in das Gitea-Repo claude-regelwerk geht, wie die Positivliste funktioniert und was der automatische Push tut. Laden, bevor der veröffentlichte Umfang geändert wird oder wenn ein Auto-Push-Befund im Kontext steht." tags: [regelwerk, git, veroeffentlichung, werkzeuge] created: 2026-09-17 -updated: 2026-10-14 +updated: 2026-10-15 --- # Veröffentlichung der Anweisungsschicht @@ -27,6 +27,8 @@ Die `.gitignore` im Vault-Root ist eine **Positivliste**: Sie schließt mit `/*` Eingeschlossen sind: `CLAUDE.md`, `README.md`, `AI/Regelwerk/`, `.claude/agents/`, `.claude/skills/`, `.claude/workflows/` (seit 2026-09-23, auf Pascals Wort), `.claude/settings.json`, `.vault-tools/`. +Ausgenommen aus `.vault-tools/` sind seit 2026-10-15 auf Pascals Wort `.vault-tools/trading-plattform-pruefung/` und `.vault-tools/watermarks-remover/`. Die Trading-Werkzeuge nennen Benutzer und Adressen des Docker-Hosts; sie sind ein eigenes lokales Git-Repo im selben Ordner. **Ein neues Werkzeug des Trading-Projekts unter `.vault-tools/` bekommt eine Ausnahmezeile in der `.gitignore`, bevor es geschrieben wird.** + Draußen bleibt damit alles andere, insbesondere `Inbox/`, `Code Projekte/`, `Security - Compliance Projekte/`, `Finanzen/`, `IT Workspace/`, `AI/sessions/`, `AI/secondbrain/`, `Bookstack/`, `Import/` und `.claude/settings.local.json`. **Wer den Umfang ändern will, ändert die `.gitignore`.** Die Pfadliste in `auto_push.py` entscheidet nichts darüber — sie gehört zum Hook-Betrieb, der seit dem 2026-09-17 nicht mehr eingetragen ist. Der Code bleibt trotzdem stehen, weil der Fallkatalog ihn fährt und ein Hook-Betrieb jederzeit wieder eingetragen werden kann. @@ -74,7 +76,7 @@ Der Fallkatalog liegt daneben: `python .vault-tools/auto-push/faelle_auto_push.p Angeordnet am 2026-10-14. Nach dem Vault-Push spiegelt derselbe 04:00-Lauf die Werkzeuge in ein zweites öffentliches Repo. - **Inhalt:** der committete Stand von `.vault-tools/` und `.claude/`, ohne `.vault-tools/trading-plattform-pruefung/` und `.vault-tools/watermarks-remover/`. Dazu `README.md` aus `.vault-tools/auto-push/werkzeuge-README.md`. -- **Neue Werkzeuge gehen automatisch mit.** Die Ausschlüsse sind eine Negativliste in `werkzeuge_spiegel.py` (`AUSSCHLUESSE`), so angeordnet („alle Werkzeuge außer Trading"). Ein neues Werkzeug des Trading-Projekts unter `.vault-tools/` trägst du dort ein, **bevor** es committet wird. +- **Neue Werkzeuge gehen automatisch mit.** Die Ausschlüsse sind eine Negativliste in `werkzeuge_spiegel.py` (`AUSSCHLUESSE`), so angeordnet („alle Werkzeuge außer Trading"). Seit die `.gitignore` dieselben Ordner ausnimmt, sind sie nie committet und damit doppelt gesperrt. Ein neues Trading-Werkzeug trägst du an beiden Stellen ein. - **Klon:** `D:\ClaudeData\claude-werkzeuge`, außerhalb des Vaults. Der Lauf leert ihn bis auf `.git` und packt neu aus. Er weigert sich, wenn `origin` nicht genau auf das Spiegel-Repo zeigt oder der Ordner im Vault liegt. - **Das Repo ist ein Spiegel.** Änderungen direkt in `claude-werkzeuge` überschreibt der nächste Lauf. - **Protokoll:** eine zweite Zeile je Lauf in `lauf.log` (`ok werkzeuge gespiegelt|unveraendert` oder `BEFUND werkzeuge: …`). Scheitert der Vault-Push, entfällt der Spiegel. diff --git a/CLAUDE.md b/CLAUDE.md index e3c3fce..bbd1c2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,7 +105,7 @@ Jede Datei triggert nur den betreffenden Teil — nicht bei jeder Aufgabe alles ## Veröffentlichung -Der Vault-Root ist ein Git-Repo und pusht **täglich um 04:00** nach `https://git.nexhorizons.de/Nexhorizons-Public/claude-regelwerk.git` — über die Windows-Aufgabe `Claude-Vault Auto-Push`, nicht über eine Cloud-Routine. Veröffentlicht wird nur die Anweisungsschicht: `CLAUDE.md`, `README.md`, `AI/Regelwerk/`, `.claude/agents/`, `.claude/skills/`, `.claude/workflows/`, `.claude/settings.json` und `.vault-tools/`. Derselbe Lauf spiegelt danach `.vault-tools/` und `.claude/` ohne die Trading-Werkzeuge nach `claude-werkzeuge`. +Der Vault-Root ist ein Git-Repo und pusht **täglich um 04:00** nach `https://git.nexhorizons.de/Nexhorizons-Public/claude-regelwerk.git` — über die Windows-Aufgabe `Claude-Vault Auto-Push`, nicht über eine Cloud-Routine. Veröffentlicht wird nur die Anweisungsschicht: `CLAUDE.md`, `README.md`, `AI/Regelwerk/`, `.claude/agents/`, `.claude/skills/`, `.claude/workflows/`, `.claude/settings.json` und `.vault-tools/` ohne die Trading-Werkzeuge und `watermarks-remover`. Derselbe Lauf spiegelt danach `.vault-tools/` und `.claude/` nach `claude-werkzeuge`. Die `.gitignore` im Root ist eine **Positivliste** — sie schließt erst alles aus und nimmt dann einzeln wieder auf. Notizen, Projekte, Finanzen, Sitzungen und `settings.local.json` bleiben damit draußen, auch wenn morgen ein neuer Ordner dazukommt.