Automatischer Push: 19 Datei(en) geändert

- .vault-tools/plan-kopie/LIESMICH.md
- .vault-tools/plan-kopie/rot_nachweis.py
- .vault-tools/plan-kopie/welle_vergleich.py
- .vault-tools/plan-kopie/welle_vergleich_treiber.py
- .vault-tools/trading-plattform-pruefung/LIESMICH.md
- .vault-tools/trading-plattform-pruefung/angriffe.py
- .vault-tools/trading-plattform-pruefung/browserlauf.py
- .vault-tools/trading-plattform-pruefung/browserlauf_zugang.py
- .vault-tools/trading-plattform-pruefung/c_eigenlauf.py
- .vault-tools/trading-plattform-pruefung/d_eigenlauf.py
- .vault-tools/trading-plattform-pruefung/handelstag_auswertung.py
- .vault-tools/trading-plattform-pruefung/ntfy_probe_server.py
- … und 7 weitere

Erzeugt vom PostToolUse-Hook .vault-tools/auto-push/auto_push.py.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Pascal MichelandClaude Opus 5 committed 2026-09-19 04:00:01 +02:00
1 parent 371a1f5d2a
commit 395642c480
19 files changed
+1636 -32

No files matched your search

@@ -29,10 +29,18 @@ Koordinator-Werkzeuge, nicht fürs Repo. Sie starten **nie** den echten Broker:
| `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 | aus dem Projektordner in WSL: `PYTHONPATH=. python start_probe_zugang.py <port> <ablage> [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 <basis-url> <ablage-mit-zugang.json>` |
| `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:<port> <ablage>/zugang.json <ausgabeordner> [--sabotage]` |
| `pruef_beobachtung_echt.py` | `baue_beobachtung` gegen `NautilusEnv._beobachtung` auf den echten IEX-Kerzen eines Tages, beide Seiten unabhängig gerechnet; Sabotage rot | `PYTHONPATH=. python pruef_beobachtung_echt.py JJJJ-MM-TT` aus dem Projektordner |
| `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 <jsonl>` |
| `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 (Füllzeit aus `submitted_at`/`filled_at` gegen `order_timeout_s`, 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 <protokoll.jsonl> [--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 [<protokoll.jsonl>]`, 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 <alt.jsonl> <neu.jsonl> [--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 <port> <mitschrift.jsonl> [--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 <pem>` 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.
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.
@@ -10,14 +10,25 @@ Geltungsbereich (Herkunft jeder Erwartung: Projektvorgabe, Spec Teil 10, Abschni
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.
Was es nicht sieht: TLS und das Secure-Merkmal (das Probe-Backend spricht HTTP), 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).
- 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.
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 <basis-url> <ablageordner des probe-backends> [--ohne-bremse]
python angriffe.py <basis-url> <ablageordner des probe-backends> [--ohne-bremse] [--ca <pem>]
--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).
"""
@@ -25,6 +36,8 @@ import base64
import hashlib
import hmac
import json
import os
import re
import struct
import sys
import time
@@ -66,6 +79,17 @@ def main() -> int:
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():
@@ -133,6 +157,25 @@ def main() -> int:
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")))
@@ -169,7 +212,11 @@ def main() -> int:
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:])
pruefe("Cookie ohne Secure (HTTP)", "secure" not 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}")
@@ -1,21 +1,41 @@
"""Browser-Lauf zu Teil 3b mit Playwright (Windows-Python), gegen ein laufendes Backend.
Koordinator-Pruefskript, nicht fuers Repo. Aufruf:
python -X utf8 browserlauf.py <basis-url> <ausgabeordner>
python -X utf8 browserlauf.py <basis-url> <ausgabeordner> [<zugang.json>]
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 = [
@@ -56,10 +76,28 @@ def main() -> int:
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").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 = browser.new_page(viewport={"width": breite, "height": 900})
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}"))
@@ -112,6 +150,20 @@ def main() -> int:
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]}")
@@ -10,11 +10,14 @@ Fragen (Herkunft: Projektvorgabe, Spec Teil 10, Teil B):
- 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: TLS und das Secure-Merkmal, Safari und Firefox (nur Chromium), ob es gut aussieht
(dafuer die Screenshots, die der Koordinator selbst ansieht).
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 <basis-url> <zugang.json> <ausgabeordner> [--sabotage]
Aufruf: python -X utf8 browserlauf_zugang.py <basis-url> <zugang.json> <ausgabeordner> [--sabotage] [--selbstsigniert]
Exit 0 ohne Befund, 2 mit Befund, 3 wenn eine Voraussetzung fehlt.
"""
@@ -47,6 +50,7 @@ def main() -> int:
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
@@ -65,7 +69,7 @@ def main() -> int:
with sync_playwright() as spiel:
browser = spiel.chromium.launch()
seite = browser.new_page(viewport={"width": 1440, "height": 900})
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(
@@ -0,0 +1,152 @@
"""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 <basis-url> <ablage> [--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())
@@ -0,0 +1,197 @@
"""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=<codebaum>, Arbeitsverzeichnis beliebig): python d_eigenlauf.py <codebaum> [--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
@@ -0,0 +1,281 @@
"""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
drei Stellen, und dort gegen Werte aus dem Protokollkopf (Projektvorgabe, config/paper.toml):
- Fuellzeit einer Order (filled_at - submitted_at laut Alpaca) gegen order_timeout_s,
- Schlussposition ungleich null,
- 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, 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.
2. Mit --broker (lesend, Paper-Konto): Orders des Tages 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; je gefuellter Order der Abstand des Fuellkurses
zum Schluss der letzten vor der Order abgeschlossenen Kerze, 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 config/secrets.toml aus dem Projektordner und gibt die Schluessel nie aus.
Aufruf aus dem Projektordner (WSL, Venv):
python handelstag_auswertung.py <paper-protokoll.jsonl> [--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
HANDEL_URL = "https://paper-api.alpaca.markets"
DATEN_URL = "https://data.alpaca.markets"
def zeitpunkt(text: str) -> datetime:
return datetime.fromisoformat(text.replace("Z", "+00:00"))
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')}"
)
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]]}")
orderschritte = set()
letzter_orders = 0
for t in takte:
if isinstance(t.get("orders"), int) and t["orders"] > letzter_orders:
orderschritte.add(t.get("schritt"))
letzter_orders = t["orders"]
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))}")
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]}")
for z in zeilen:
if z.get("art") == "ereignis":
print(f"ereignis {z.get('zeit')}: {z.get('was')} | {str(z.get('text'))[:160]} | phase {z.get('phase')}")
for z in zeilen:
if z.get("art") == "order":
print(f"order {z.get('zeit')}: {z.get('seite')} {z.get('menge')} gefuellt {z.get('gefuellt')} status {z.get('status')} id {z.get('id')}")
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")
return kopf
def kopfzeilen() -> dict[str, str] | None:
secrets = Path("config/secrets.toml")
if not secrets.exists():
print(f"VORAUSSETZUNG FEHLT: {secrets} (aus dem Projektordner starten)")
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": "AAPL",
"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})")
for o in orders:
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]}"
)
if dauer is not None and grenze and dauer > grenze:
print(f" BEFUND: Fuellzeit {dauer:.1f} s ueber 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]) -> None:
import requests
parameter = {
"symbols": "AAPL",
"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",
}
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
kerzen = (antwort.json().get("bars") or {}).get("AAPL") or []
print(f"kerzen ({feed}) am {tag_start.date()}: {len(kerzen)}")
nach_zeit = {zeitpunkt(k["t"]): k for k in kerzen}
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(entscheidungskerze)
if kerze is None:
print(f" {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('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)
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)
@@ -0,0 +1,99 @@
"""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 <port> <mitschrift.jsonl> [--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())
@@ -0,0 +1,146 @@
"""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)
@@ -0,0 +1,122 @@
"""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 <alt.jsonl> <neu.jsonl> [--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)
@@ -8,12 +8,23 @@ Beobachtung wie der Weg des Trainings (dieselbe REST-Antwort -> alpaca_json_zu_b
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.
Was es nicht sieht: Beobachtungen mit offener Position (Position und Kontowert kommen im Betrieb vom
Broker), SIP gegen IEX (gemessen in der Vorabmessung), andere Instrumente und Tage.
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]
python pruef_beobachtung_echt.py 2026-09-17 [--sabotage] [--mutant-vorzeichen]
"""
import dataclasses
@@ -53,6 +64,7 @@ def main() -> int:
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.
@@ -150,10 +162,60 @@ def main() -> int:
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"{verglichen} Beobachtungen verglichen, {ungleich} ungleich, groesster Abstand {groesste:.3e}")
if ungleich:
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
@@ -0,0 +1,52 @@
"""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 [<protokoll.jsonl>] (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())
@@ -6,7 +6,10 @@ Beispielgeheimnis der TOTP-Literatur; beides steht in zugang.json im Ausgabeordn
angriffe.py und der Browser-Lauf sich anmelden koennen.
Aufruf aus dem Projektordner (oder einer Kopie davon):
python start_probe_zugang.py <port> <ausgabeordner> [mit-paperstatus]
python start_probe_zugang.py <port> <ausgabeordner> [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
@@ -34,13 +37,20 @@ GEHEIMNIS = "JBSWY3DPEHPK3PXP"
port = int(sys.argv[1])
ordner = Path(sys.argv[2])
ordner.mkdir(parents=True, exist_ok=True)
mit_status = len(sys.argv) > 3 and sys.argv[3] == "mit-paperstatus"
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"
schreibe_benutzer(
zugang_datei,
{BENUTZER: Benutzer(name=BENUTZER, passwort=setze_passwort(PASSWORT), totp_geheimnis=GEHEIMNIS)},
)
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",
@@ -0,0 +1,115 @@
"""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 config/secrets.toml aus dem Projektordner 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
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 = Path("config/secrets.toml")
if not secrets.is_file():
print("VORAUSSETZUNG FEHLT: config/secrets.toml")
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)