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:
1 parent
371a1f5d2a
commit
395642c480
19 files changed
+1636
-32
No files matched your search
@@ -21,8 +21,12 @@ Die Regel dazu steht in [[AI/Regelwerk/codeworkflow|codeworkflow.md]] („Die Ko
|
||||
5. **Nach jeder Welle:** `welle_vergleich.py --projekt <projekt> --mat <kopie> [--prosa <rel> …] -- <taskdateien der Welle>`. Prüft, ob genau die Zieldateien geändert sind und ob jede byte-gleich mit der Kopie ist. Es zeigt je Datei nur die erste Abweichung — danach `diff` auf die ganze Datei. Eigene Plankorrekturen im Baum meldet es zu Recht als „außerhalb der Zieldateien".
|
||||
6. **Escape-Folgen setzen:** `plan_escape.py <datei> <zeile> <codepunkt-hex> …` ersetzt echte Zeichen in einer Zeile durch ihre Escape-Folge, ohne dass eine Schreibschicht mitredet.
|
||||
|
||||
7. **Rot-Nachweis je Task (seit 2026-09-18):** `rot_nachweis.py <basis-kopie> <planordner> <arbeitsordner> <spec.json> [--fixturen <ordner>] [--node-modules <pfad>]`. `plan_materialisiere.py` wendet eine Taskdatei immer ganz an; „erst der Test, dann die Umsetzung" lässt sich damit nicht beobachten. Das Skript stellt den Zustand dazwischen auf Wegwerfkopien her — Basis plus Voraussetzungstasks plus der Task, danach die produktiven Zieldateien zurück auf den Stand vor dem Task — und fährt die Testdateien des Tasks (pytest in WSL, vitest unter Windows über eine Junction, die es vor dem Löschen löst). Es meldet je Task `ROT` mit der ersten Fehlerzeile oder `GRUEN`; ein Test, der ohne Umsetzung grün ist, misst sie nicht. Die Basis-Kopie zieht man **vor** der ersten Welle. `spec.json` nennt je Task die Voraussetzungstasks. Erster Einsatz am 2026-09-18 an vier Plänen der Trading Plattform: 35 Tasks, alle rot an der gemeinten Stelle, keiner grün ohne Umsetzung. Die Executer brauchen den Tanz mit dem Beiseitelegen der Produktivdatei dann nicht mehr; ihr Auftrag sagt das ausdrücklich.
|
||||
|
||||
## Was belegt ist
|
||||
|
||||
Seit 2026-09-18 nimmt `welle_vergleich.py` unter `## Zieldateien` auch Pfade relativ zum Projekt (ohne Laufwerk und ohne Wurzel), weil Pläne für einen Git-Worktree keine absoluten Pfade nennen; ein `..` darin ist ein Befund wie ein fremder absoluter Pfad. Jede Zeile braucht weiter die Klammer hinter dem Pfad (`(neu)`, `(geändert)`, `(gelöscht)`). `plan_materialisiere.py` erkennt eine Zieldatei ohne Schrägstrich nur mit den Endungen py, ts, tsx, toml, md, css und json — für `compose.yaml` in der Projektwurzel schreibt die Überschrift `./compose.yaml`. Der Treiber fährt dazu drei weitere Fälle (relative Grundlinie, relative Abweichung, `..` nach außen), zusammen 17.
|
||||
|
||||
`welle_vergleich_treiber.py` fährt 14 Angriffsfälle gegen `welle_vergleich.py` (Grundlinie zuerst; fremde Datei, unveränderte Zieldatei, Abweichung in erster und letzter Zeile und in der letzten Zieldatei, CRLF, Datei fehlt in der Kopie, Task ohne Zieldateien, Kopie fehlt, Löschung nicht ausgeführt, Prosa-Datei unverändert, Zieldatei außerhalb des Projekts, Datei länger als der Plan). `plan_zeichen.py` hat am 2026-09-17 an einem echten Fund rot gezeigt (U+2028 im Plan). `plan_materialisiere.py` reproduziert Task 108 des Teil-3b-Plans aus dem Stand vor den Wellen byte-genau. `kopie_bauen.py` ist am 2026-09-17 an drei kaputten Eingaben rot gesehen: Ziel existiert schon (Exit 2, nichts kopiert), Ausschluss `node_modules` ohne Gegenstück im Wurzelordner (Befund), und ein aus der Kopie entferntes Paket `trading/data` (die Paketprüfung nennt es; nach dem Zurücklegen leer). Einen Treiber dazu gibt es noch nicht — die drei Fälle sind von Hand gefahren.
|
||||
|
||||
## Was die Skripte nicht sehen
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Rot-Nachweis je Task: die neuen Tests eines Tasks gegen den Produktivcode VOR dem Task, auf Wegwerfkopien.
|
||||
|
||||
Koordinator-Werkzeug (entstanden 2026-09-18, Trading Plattform). plan_materialisiere.py wendet eine
|
||||
Taskdatei immer ganz an; "erst der Test, dann die Umsetzung" laesst sich damit nicht beobachten. Dieses
|
||||
Skript stellt den Zustand dazwischen her: Basis + Voraussetzungstasks + der Task selbst, danach die
|
||||
PRODUKTIVEN Zieldateien des Tasks zurueck auf den Stand vor dem Task (neue Produktivdateien geloescht).
|
||||
Dann laufen die Testdateien des Tasks. Es tut etwas und berichtet; das Urteil faellt der Koordinator:
|
||||
ROT mindestens ein Fehlschlag oder Sammelfehler -- die erste Fehlerzeile steht dabei, damit man sieht,
|
||||
ob der Test an der gemeinten Stelle faellt und nicht an einem Tippfehler.
|
||||
GRUEN kein Fehlschlag ohne die Umsetzung -- ein Test, der so gruen ist, misst die Umsetzung nicht
|
||||
(ausser der Plan fuehrt ihn ausdruecklich als Regressionsnetz).
|
||||
Geltungsbereich und Luecken:
|
||||
- Testdateien sind Zieldateien unter tests/ sowie *.test.ts und *.test.tsx; alles unter tests/fixtures/
|
||||
gilt als Testseite und bleibt stehen. Alles andere ist produktiv.
|
||||
- Python-Tests laufen in WSL (Debian, ~/venvs/trading), Oberflaechentests unter Windows mit npx vitest;
|
||||
dafuer setzt das Skript eine Junction auf das node_modules des Projekts und loest sie vor dem Loeschen.
|
||||
- Erzeugte Fixtures (der Plan laesst sie mit altem Code erzeugen) holt --fixturen aus einem Ordner nach,
|
||||
wenn sie nach dem Anwenden fehlen.
|
||||
- Es sieht nicht, WELCHE Tests rot sein muessten; es zaehlt und zeigt die erste Fehlerzeile.
|
||||
- Die Basis ist eine Kopie, die der Aufrufer VOR der ersten Welle gezogen hat; das Skript fasst das
|
||||
Projekt selbst nie an.
|
||||
|
||||
Aufruf: python rot_nachweis.py <basis-kopie> <planordner> <arbeitsordner> <spec.json> [--fixturen <ordner>] [--node-modules <pfad>]
|
||||
--node-modules nennt das node_modules des Projekts, wenn die Basis-Kopie keines traegt.
|
||||
spec.json: [{"task": "task-141-....md", "vor": ["task-...md", ...]}, ...]
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HIER = Path(__file__).resolve().parent
|
||||
ZIEL = re.compile(r"^- `(.+?)`")
|
||||
PROJEKTNAME = "trading-plattform/"
|
||||
|
||||
|
||||
def zieldateien(taskdatei: Path) -> list[str]:
|
||||
"""Zieldateien relativ zum Projekt. Absolute Pfade werden hinter dem Projektordner abgeschnitten."""
|
||||
drin = False
|
||||
ziele: list[str] = []
|
||||
for zeile in taskdatei.read_text(encoding="utf-8").replace("\r\n", "\n").split("\n"):
|
||||
if zeile.startswith("## "):
|
||||
drin = zeile.startswith("## Zieldateien")
|
||||
continue
|
||||
treffer = ZIEL.match(zeile) if drin else None
|
||||
if treffer:
|
||||
pfad = treffer.group(1).replace("\\", "/").removeprefix("./")
|
||||
if re.match(r"^[A-Za-z]:/", pfad):
|
||||
stelle = pfad.lower().find(PROJEKTNAME)
|
||||
if stelle < 0:
|
||||
raise ValueError(f"{taskdatei.name}: absoluter Pfad ausserhalb des Projekts: {pfad}")
|
||||
pfad = pfad[stelle + len(PROJEKTNAME) :]
|
||||
ziele.append(pfad)
|
||||
return ziele
|
||||
|
||||
|
||||
def ist_test(pfad: str) -> bool:
|
||||
return pfad.startswith("tests/") or pfad.endswith((".test.ts", ".test.tsx"))
|
||||
|
||||
|
||||
def wende_an(planordner: Path, kopie: Path, tasks: list[str]) -> str:
|
||||
if not tasks:
|
||||
return ""
|
||||
lauf = subprocess.run(
|
||||
[sys.executable, "-X", "utf8", str(HIER / "plan_materialisiere.py"), str(planordner), str(kopie), *tasks],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
)
|
||||
return "" if lauf.returncode == 0 else (lauf.stdout + lauf.stderr)[-600:]
|
||||
|
||||
|
||||
def wsl_pfad(pfad: Path) -> str:
|
||||
text = str(pfad.resolve()).replace("\\", "/")
|
||||
return f"/mnt/{text[0].lower()}{text[2:]}"
|
||||
|
||||
|
||||
def loesche(ordner: Path) -> None:
|
||||
junction = ordner / "dashboard" / "node_modules"
|
||||
if junction.exists():
|
||||
os.rmdir(junction) # loest nur die Verknuepfung, nie das Ziel
|
||||
shutil.rmtree(ordner, ignore_errors=True)
|
||||
|
||||
|
||||
def erste_fehlerzeile(ausgabe: str) -> str:
|
||||
for zeile in ausgabe.splitlines():
|
||||
kern = zeile.strip()
|
||||
if kern.startswith(("E ", "FAILED", "ERROR", "AssertionError", "Error:", "TypeError", "ReferenceError")) or " FAIL " in zeile:
|
||||
return kern[:220]
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
argumente = sys.argv[1:]
|
||||
fixturen = None
|
||||
if "--fixturen" in argumente:
|
||||
stelle = argumente.index("--fixturen")
|
||||
fixturen = Path(argumente[stelle + 1])
|
||||
del argumente[stelle : stelle + 2]
|
||||
node_modules_arg = None
|
||||
if "--node-modules" in argumente:
|
||||
stelle = argumente.index("--node-modules")
|
||||
node_modules_arg = Path(argumente[stelle + 1])
|
||||
del argumente[stelle : stelle + 2]
|
||||
if len(argumente) != 4:
|
||||
print(__doc__)
|
||||
return 3
|
||||
basis, planordner, arbeit, spec_pfad = (Path(a) for a in argumente)
|
||||
if not basis.is_dir() or not planordner.is_dir() or not spec_pfad.is_file():
|
||||
print("VORAUSSETZUNG FEHLT: Basis-Kopie, Planordner oder Spec")
|
||||
return 3
|
||||
arbeit.mkdir(parents=True, exist_ok=True)
|
||||
node_modules = None
|
||||
for kandidat in (node_modules_arg, basis / "dashboard" / "node_modules"):
|
||||
if kandidat is not None and kandidat.exists():
|
||||
node_modules = kandidat
|
||||
break
|
||||
ergebnisse = []
|
||||
for eintrag in json.loads(spec_pfad.read_text(encoding="utf-8")):
|
||||
task, voraus = eintrag["task"], eintrag.get("vor", [])
|
||||
name = task.split("-")[1] if "-" in task else task
|
||||
vor, rot = arbeit / f"{name}_vor", arbeit / f"{name}_rot"
|
||||
for ordner in (vor, rot):
|
||||
loesche(ordner)
|
||||
shutil.copytree(basis, vor, ignore=shutil.ignore_patterns("node_modules", "__pycache__"))
|
||||
fehler = wende_an(planordner, vor, voraus)
|
||||
shutil.copytree(vor, rot)
|
||||
fehler = fehler or wende_an(planordner, rot, [task])
|
||||
if fehler:
|
||||
ergebnisse.append((task, "ANWENDEN GESCHEITERT", fehler.replace("\n", " | ")))
|
||||
print(f"{task}: ANWENDEN GESCHEITERT: {fehler[-300:]}")
|
||||
continue
|
||||
ziele = zieldateien(planordner / task)
|
||||
tests = [z for z in ziele if ist_test(z) and not z.startswith("tests/fixtures/")]
|
||||
for ziel in ziele:
|
||||
if ist_test(ziel):
|
||||
if not (rot / ziel).exists() and fixturen is not None and (fixturen / Path(ziel).name).is_file():
|
||||
(rot / ziel).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(fixturen / Path(ziel).name, rot / ziel)
|
||||
continue
|
||||
if (vor / ziel).is_file():
|
||||
shutil.copyfile(vor / ziel, rot / ziel)
|
||||
elif (rot / ziel).is_file():
|
||||
(rot / ziel).unlink()
|
||||
python_tests = [t for t in tests if t.endswith(".py")]
|
||||
ober_tests = [t for t in tests if not t.endswith(".py")]
|
||||
zeilen = []
|
||||
if python_tests:
|
||||
befehl = f"cd {wsl_pfad(rot)} && ~/venvs/trading/bin/python -m pytest {' '.join(python_tests)} -q -p no:cacheprovider 2>&1 | tail -60"
|
||||
lauf = subprocess.run(["wsl.exe", "-d", "Debian", "--", "bash", "-lc", befehl],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
ausgabe = lauf.stdout
|
||||
summe = next((z.strip() for z in reversed(ausgabe.splitlines()) if re.search(r"\d+ (passed|failed|error)", z)), "keine Summenzeile")
|
||||
# Gruen ist nur ein Lauf mit Summenzeile und ohne Fehlschlag. Ohne Summenzeile hat pytest gar
|
||||
# nicht gesammelt (etwa ein ImportError in tests/conftest.py) -- das ist rot, nicht gruen.
|
||||
# Gefunden am 2026-09-18: Task 172 stand als GRUEN da, obwohl kein einziger Test lief.
|
||||
rot_ja = bool(re.search(r"\d+ (failed|error)", summe)) or summe == "keine Summenzeile"
|
||||
zeilen.append(("ROT " if rot_ja else "GRUEN") + f" pytest: {summe} | {erste_fehlerzeile(ausgabe)}")
|
||||
if ober_tests:
|
||||
if node_modules is None:
|
||||
zeilen.append("VORAUSSETZUNG FEHLT: node_modules in der Basis-Kopie")
|
||||
else:
|
||||
import _winapi
|
||||
_winapi.CreateJunction(str(node_modules.resolve()), str(rot / "dashboard" / "node_modules"))
|
||||
relativ = [t.removeprefix("dashboard/") for t in ober_tests]
|
||||
lauf = subprocess.run(["npx.cmd", "vitest", "run", *relativ], cwd=rot / "dashboard",
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
ausgabe = re.sub(r"\x1b\[[0-9;]*m", "", lauf.stdout + lauf.stderr)
|
||||
summe = next((z.strip() for z in ausgabe.splitlines() if z.strip().startswith("Tests ")), "")
|
||||
if not summe:
|
||||
# Ohne Summenzeile ist vitest nicht bis zum Ende gekommen (Importfehler, Startfehler):
|
||||
# das Ende der Ausgabe zeigen, statt "rot" zu behaupten, ohne zu wissen woran.
|
||||
summe = "keine Summenzeile; Ende der Ausgabe: " + " / ".join(z.strip() for z in ausgabe.splitlines()[-6:] if z.strip())[:300]
|
||||
rot_ja = "failed" in summe or lauf.returncode != 0
|
||||
zeilen.append(("ROT " if rot_ja else "GRUEN") + f" vitest: {summe} | {erste_fehlerzeile(ausgabe)}")
|
||||
if not tests:
|
||||
zeilen.append("ohne Testdatei unter den Zieldateien")
|
||||
for zeile in zeilen:
|
||||
print(f"{task}: {zeile}")
|
||||
ergebnisse.append((task, zeile[:5].strip(), zeile))
|
||||
for ordner in (vor, rot):
|
||||
loesche(ordner)
|
||||
gruen = [e for e in ergebnisse if e[1] == "GRUEN"]
|
||||
print(f"\n{len(ergebnisse)} Laeufe, davon {len(gruen)} gruen ohne Umsetzung.")
|
||||
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)
|
||||
@@ -23,7 +23,9 @@ import sys
|
||||
import traceback
|
||||
from pathlib import Path, PureWindowsPath
|
||||
|
||||
ZIEL = re.compile(r"^- `(.+?)`\s*\((.*)\)\s*$")
|
||||
# Seit 2026-09-18 ist die Klammer hinter dem Pfad wahlfrei, und dahinter darf Text stehen
|
||||
# ("- `pfad` -- nur die Konstante X"): Plaene nennen Zieldateien nicht immer mit "(neu)".
|
||||
ZIEL = re.compile(r"^- `(.+?)`(?:\s*\((.*?)\))?")
|
||||
|
||||
|
||||
def zieldateien(taskdatei: Path, projekt: Path, befunde: list[str]) -> dict[str, bool]:
|
||||
@@ -45,12 +47,22 @@ def zieldateien(taskdatei: Path, projekt: Path, befunde: list[str]) -> dict[str,
|
||||
if not treffer:
|
||||
continue
|
||||
absolut = PureWindowsPath(treffer.group(1))
|
||||
try:
|
||||
rel = absolut.relative_to(PureWindowsPath(str(projekt)))
|
||||
except ValueError:
|
||||
befunde.append(f"{taskdatei.name}: Zieldatei {absolut} liegt nicht unter dem Projekt {projekt}")
|
||||
continue
|
||||
ziele[rel.as_posix()] = "gelöscht" in treffer.group(2) or "geloescht" in treffer.group(2)
|
||||
if absolut.drive == "" and absolut.root == "":
|
||||
# Seit 2026-09-18: Ein Pfad ohne Laufwerk und ohne Wurzel gilt relativ zum Projekt
|
||||
# (Plaene fuer einen Git-Worktree nennen keine absoluten Pfade). Ein ".." fuehrte
|
||||
# aus dem Projekt hinaus und ist ein Befund wie ein fremder absoluter Pfad.
|
||||
if ".." in absolut.parts:
|
||||
befunde.append(f"{taskdatei.name}: Zieldatei {absolut} liegt nicht unter dem Projekt {projekt}")
|
||||
continue
|
||||
rel = absolut
|
||||
else:
|
||||
try:
|
||||
rel = absolut.relative_to(PureWindowsPath(str(projekt)))
|
||||
except ValueError:
|
||||
befunde.append(f"{taskdatei.name}: Zieldatei {absolut} liegt nicht unter dem Projekt {projekt}")
|
||||
continue
|
||||
klammer = treffer.group(2) or ""
|
||||
ziele[rel.as_posix()] = "gelöscht" in klammer or "geloescht" in klammer
|
||||
if not ziele:
|
||||
befunde.append(f"{taskdatei.name}: keine Zieldateien gefunden -- ohne sie misst der Lauf nichts")
|
||||
return ziele
|
||||
|
||||
@@ -114,6 +114,43 @@ def fall_laengere_datei(projekt, mat, task, wurzel):
|
||||
schreibe(projekt / "a.py", "EINS\nzwei\nDREI\nvier\n")
|
||||
|
||||
|
||||
def _relativ(task, projekt):
|
||||
"""Schreibt die Zieldateien der Taskdatei relativ zum Projekt, eine davon mit Schraegstrich."""
|
||||
text = task.read_text(encoding="utf-8")
|
||||
schreibe(task, text.replace(f"{projekt}\\", "").replace("sub\\neu.py", "sub/neu.py"))
|
||||
|
||||
|
||||
def fall_relativ_grundlinie(projekt, mat, task, wurzel):
|
||||
_relativ(task, projekt)
|
||||
|
||||
|
||||
def fall_relativ_abweichung(projekt, mat, task, wurzel):
|
||||
_relativ(task, projekt)
|
||||
schreibe(projekt / "sub" / "neu.py", "neu eins\nneu ZWEI\n")
|
||||
|
||||
|
||||
def _ohne_klammer(task):
|
||||
"""Nimmt den Zieldateien die Klammer (ausser der Loeschmarke) und haengt einer Zeile Text an."""
|
||||
text = task.read_text(encoding="utf-8")
|
||||
text = text.replace("a.py` (geändert)", "a.py` — **nur die erste und die letzte Zeile**")
|
||||
schreibe(task, text.replace(" (neu)", "").replace("doku.md` (geändert)", "doku.md`"))
|
||||
|
||||
|
||||
def fall_ohne_klammer_grundlinie(projekt, mat, task, wurzel):
|
||||
_ohne_klammer(task)
|
||||
|
||||
|
||||
def fall_ohne_klammer_abweichung(projekt, mat, task, wurzel):
|
||||
_ohne_klammer(task)
|
||||
schreibe(projekt / "a.py", "Eins\nzwei\nDREI\n")
|
||||
|
||||
|
||||
def fall_relativ_nach_aussen(projekt, mat, task, wurzel):
|
||||
_relativ(task, projekt)
|
||||
text = task.read_text(encoding="utf-8")
|
||||
schreibe(task, text.replace("Keine andere Datei", "- `../anderswo/x.py` (neu)\n\nKeine andere Datei"))
|
||||
|
||||
|
||||
FAELLE = [
|
||||
("Grundlinie", fall_grundlinie, 0, "0 Befund(e)."),
|
||||
("fremde Datei im Baum", fall_fremde_datei, 2, "fremd.py: geaendert"),
|
||||
@@ -130,6 +167,13 @@ FAELLE = [
|
||||
("Zieldatei ausserhalb des Projekts", fall_ziel_ausserhalb, 2, "liegt nicht unter dem Projekt"),
|
||||
# Die angehaengte Zeile steht der leeren Restzeile der Kopie gegenueber, deshalb "Zeile 4" und nicht "Laenge".
|
||||
("Datei laenger als der Plan", fall_laengere_datei, 2, "a.py: weicht vom Plan ab -- Zeile 4: Baum b'vier'"),
|
||||
# Seit 2026-09-18: Zieldateien relativ zum Projekt (Plaene fuer einen Git-Worktree).
|
||||
("Zieldateien relativ zum Projekt", fall_relativ_grundlinie, 0, "0 Befund(e)."),
|
||||
("relative Zieldatei weicht ab", fall_relativ_abweichung, 2, "sub/neu.py: weicht vom Plan ab -- Zeile 2"),
|
||||
("relative Zieldatei fuehrt nach aussen", fall_relativ_nach_aussen, 2, "liegt nicht unter dem Projekt"),
|
||||
# Seit 2026-09-18: Die Klammer hinter dem Pfad ist wahlfrei, Text dahinter erlaubt.
|
||||
("Zieldateien ohne Klammer", fall_ohne_klammer_grundlinie, 0, "0 Befund(e)."),
|
||||
("Zieldatei ohne Klammer weicht ab", fall_ohne_klammer_abweichung, 2, "a.py: weicht vom Plan ab -- Zeile 1"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user