Automatischer Push: 11 Datei(en) geändert
- .vault-tools/trading-plattform-pruefung/LIESMICH.md - .vault-tools/trading-plattform-pruefung/abendkontrolle.py - .vault-tools/trading-plattform-pruefung/abendkontrolle_angriffe.py - .vault-tools/trading-plattform-pruefung/abendkontrolle_mutanten.py - .vault-tools/trading-plattform-pruefung/abendkontrolle_wsl.py - .vault-tools/trading-plattform-pruefung/browserlauf.py - .vault-tools/trading-plattform-pruefung/browserlauf_zugang.py - .vault-tools/trading-plattform-pruefung/ha_totmann.py - .vault-tools/trading-plattform-pruefung/ha_totmann_wsl.py - .vault-tools/trading-plattform-pruefung/scan_push.sh - AI/Regelwerk/codeworkflow.md 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
ea278dc24f
commit
c115a13190
11 files changed
+1848
-37
No files matched your search
@@ -124,6 +124,7 @@ Pascal am 2026-09-24: eine dauerhafte Abendkontrolle, die Neustarts übersteht u
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `dashboard_neustart.sh` | `sudo systemctl restart trading-dashboard.service` nur bei stehendem Runner, danach Tor 6b (Journal der letzten Minute ohne `PermissionError`/`Traceback`, sonst Exit 27), `/api/gesund` und die Oberfläche. Pascals dauerhafte Freigabe gilt für genau diesen Neustart außerhalb der Handelszeit | mit `WSL_PW` per WSLENV: `wsl -d Debian -u claude -- bash …/dashboard_neustart.sh` |
|
| `dashboard_neustart.sh` | `sudo systemctl restart trading-dashboard.service` nur bei stehendem Runner, danach Tor 6b (Journal der letzten Minute ohne `PermissionError`/`Traceback`, sonst Exit 27), `/api/gesund` und die Oberfläche. Pascals dauerhafte Freigabe gilt für genau diesen Neustart außerhalb der Handelszeit | mit `WSL_PW` per WSLENV: `wsl -d Debian -u claude -- bash …/dashboard_neustart.sh` |
|
||||||
| `geheimnis_scan.py` | vor jedem Push: Werte ab 12 Zeichen aus Geheimnis- und Zugangsdatei gegen `git log -p <von>..<bis>`, nur Schlüsselnamen und Trefferzahlen; dazu, ob eine der drei Geheimnisdateien verfolgt wird. Regressionsnachweis mit `SCAN_PROBE=<bekannter Text>` (am 2026-09-24: 66 Treffer für einen bekannten Text, 0 für die echten Werte) | in WSL: `python geheimnis_scan.py <repo> <von> <bis> [<bis> …]` |
|
| `geheimnis_scan.py` | vor jedem Push: Werte ab 12 Zeichen aus Geheimnis- und Zugangsdatei gegen `git log -p <von>..<bis>`, nur Schlüsselnamen und Trefferzahlen; dazu, ob eine der drei Geheimnisdateien verfolgt wird. Regressionsnachweis mit `SCAN_PROBE=<bekannter Text>` (am 2026-09-24: 66 Treffer für einen bekannten Text, 0 für die echten Werte) | in WSL: `python geheimnis_scan.py <repo> <von> <bis> [<bis> …]` |
|
||||||
|
| `scan_push.sh` | Hülle um `geheimnis_scan.py` für den Projektordner: zählt die Commits im Bereich, fährt erst die Probe (`SCAN_PROBE_TEXT`, Vorgabe „Nachholtage melden“, ab 12 Zeichen) und dann den Scan. Nötig, weil aus der Git-Bash `$VAR` leer in WSL ankommt und der Scan dann „0 Zeichen Verlauf, 0 Treffer“ meldet (2026-09-25). Arbeitsbäume taugen nicht als `<repo>`: ihr `.git` zeigt auf einen Windows-Pfad | aus der PowerShell: `wsl -d Debian -- bash /mnt/d/Claude/.vault-tools/trading-plattform-pruefung/scan_push.sh <von> <bis>` |
|
||||||
|
|
||||||
## IBKR-Zugang (2026-09-24): Paper-Konto über das Gateway im Container
|
## IBKR-Zugang (2026-09-24): Paper-Konto über das Gateway im Container
|
||||||
|
|
||||||
@@ -143,3 +144,25 @@ Pascal am 2026-09-24: Zugang zum IBKR-Paper-Konto vorbereiten, Gateway als Conta
|
|||||||
| `af_registerprobe.py` | fragt die echte Funktion `pruefe_register` (Paket AF) gegen `data/registry.db`, nur lesend (`mode=ro`; die Klasse `Registry` fuehrte beim Oeffnen das Schema aus und schriebe); druckt Zustand, die vier juengsten Killswitch-Ereignisse (Stufe, Ausloeser, Zeit) und den Befund; Exit 0 erfuellt, 1 nicht erfuellt, 3 Register fehlt. Belegt am 2026-09-24 an drei gespielten Registern (bereit / Pascals Anhalten ohne Fortsetzen / aktiv) und an einer Kopie des echten; die mtime des echten blieb gleich. Braucht den Code mit AF im genannten Ordner | in WSL im Projektordner nach dem Dashboard-Neustart: `~/venvs/trading/bin/python af_registerprobe.py .` |
|
| `af_registerprobe.py` | fragt die echte Funktion `pruefe_register` (Paket AF) gegen `data/registry.db`, nur lesend (`mode=ro`; die Klasse `Registry` fuehrte beim Oeffnen das Schema aus und schriebe); druckt Zustand, die vier juengsten Killswitch-Ereignisse (Stufe, Ausloeser, Zeit) und den Befund; Exit 0 erfuellt, 1 nicht erfuellt, 3 Register fehlt. Belegt am 2026-09-24 an drei gespielten Registern (bereit / Pascals Anhalten ohne Fortsetzen / aktiv) und an einer Kopie des echten; die mtime des echten blieb gleich. Braucht den Code mit AF im genannten Ordner | in WSL im Projektordner nach dem Dashboard-Neustart: `~/venvs/trading/bin/python af_registerprobe.py .` |
|
||||||
|
|
||||||
Was die uebrigen Werkzeuge mit AF sehen: `handelstag_auswertung.py` braucht keine Aenderung, es zaehlt das Ereignis `auto_fortsetzen` unter `ereignisse je art` und druckt seinen Text. `abendkontrolle_wsl.py` liest Zustand, Seit und Ausloeser des Killswitch und berichtet jede Zeile `auto_fortsetzen` als Hinweis (die ersten 200 Zeichen des Feldes text, das mit dem Ergebnis beginnt). `neustart_echtlauf.py` setzt `angehalten`/`neustart` ohne Ereignis, `pause_echtlauf.py` einen Ausloeser `echtlauf`: Die Sitzung setzt dort nicht fort, schreibt aber je Lauf eine Zeile `auto_fortsetzen` und in "Paper-Runner wartet" eine Zeile mehr. `generalprobe_echtlauf.py` und `killswitch_simulation.py` laden `config/paper.toml` und klicken nach Plan; gegen einen Baum mit AF setzt die Sitzung dort mit dem Schalter selbst fort, bevor der geplante Klick kommt. Wer diese vier fuer einen Vergleich mit Paket M faehrt, setzt vorher `auto_fortsetzen=False` in ihr `replace(lade_paper_config(...), ...)`; `neustart_echtlauf.py` und `pause_echtlauf.py` rufen `lade_paper_config` ohne `replace` auf — dort in der Konfiguration ihrer Kopie `[auto_fortsetzen] aktiv = false` setzen.
|
Was die uebrigen Werkzeuge mit AF sehen: `handelstag_auswertung.py` braucht keine Aenderung, es zaehlt das Ereignis `auto_fortsetzen` unter `ereignisse je art` und druckt seinen Text. `abendkontrolle_wsl.py` liest Zustand, Seit und Ausloeser des Killswitch und berichtet jede Zeile `auto_fortsetzen` als Hinweis (die ersten 200 Zeichen des Feldes text, das mit dem Ergebnis beginnt). `neustart_echtlauf.py` setzt `angehalten`/`neustart` ohne Ereignis, `pause_echtlauf.py` einen Ausloeser `echtlauf`: Die Sitzung setzt dort nicht fort, schreibt aber je Lauf eine Zeile `auto_fortsetzen` und in "Paper-Runner wartet" eine Zeile mehr. `generalprobe_echtlauf.py` und `killswitch_simulation.py` laden `config/paper.toml` und klicken nach Plan; gegen einen Baum mit AF setzt die Sitzung dort mit dem Schalter selbst fort, bevor der geplante Klick kommt. Wer diese vier fuer einen Vergleich mit Paket M faehrt, setzt vorher `auto_fortsetzen=False` in ihr `replace(lade_paper_config(...), ...)`; `neustart_echtlauf.py` und `pause_echtlauf.py` rufen `lade_paper_config` ohne `replace` auf — dort in der Konfiguration ihrer Kopie `[auto_fortsetzen] aktiv = false` setzen.
|
||||||
|
|
||||||
|
## Paket Z2 (2026-09-26): Totmann in Home Assistant
|
||||||
|
|
||||||
|
| Datei | Was sie tut | Aufruf |
|
||||||
|
|---|---|---|
|
||||||
|
| `ha_totmann.py` | richtet in Home Assistant eine Automation ein, die im Handelsfenster (aus dem Kalender von Alpaca, 400 Tage, als Epoch-Sekunden in der Bedingung) meldet, wenn `input_datetime.trading_gesendet` aelter als 15 Minuten ist, einmal je Stille und Handelstag, ueber einen App-Dienst aus `--app-dienst` und ueber `rest_command.trading_totmann_ntfy`; Adresse mit Thema und Zugriff stehen nur in `/config/secrets.yaml` von Home Assistant, der Block `rest_command:` mit `!secret` in `/config/configuration.yaml`, beide zwischen Marken und ueber die File-editor-API geschrieben. `pruefen` laesst Home Assistant die Bedingung mit acht gestellten Lagen per `render_template` rechnen, `trocken` rechnet dieselben Lagen lokal, ohne Netz | unter Windows mit dem Python des HA-Projekts: `ha_totmann.py pruefen <ha-projekt> <trading-projekt>`, dann `einrichten ... --app-dienst notify.<name>` (Exit 4: Home Assistant neu starten, dann noch einmal), `probe ... --app-dienst notify.<name>` (zwei echte Meldungen), Rueckweg `entfernen ...`; `ha_totmann.py trocken` ohne Ordner |
|
||||||
|
| `ha_totmann_wsl.py` | WSL-Teil von `ha_totmann.py`: liest in WSL aus der Geheimnisdatei der Plattform Thema, Token und die Alpaca-Schluessel, fragt den Kalender von Alpaca und gibt genau eine JSON-Zeile auf stdout; `ha_totmann.py einrichten` startet ihn ueber `wsl.exe --exec` und liest die Zeile nur als Pipe. Die Geheimnisdatei verlaesst WSL nicht | nur ueber `ha_totmann.py einrichten`; nie von Hand, sonst stuende das Thema auf der Konsole |
|
||||||
|
|
||||||
|
Was es nicht tut: Es aendert nichts an der Automation der Kennzahlen aus `ha_einrichten.py` und nichts an deren Helfern; es startet Home Assistant nie selbst neu. Der Kalender in der Bedingung laeuft nach 400 Tagen aus, danach schweigt der Totmann; der Alias der Automation nennt den letzten Tag, `pruefen` zeigt ihn, `einrichten` verlaengert.
|
||||||
|
|
||||||
|
## Paket IB2 (2026-09-25): Abendkontrolle der IBKR-Instanz
|
||||||
|
|
||||||
|
Seit Paket IB2 prüft die Abendkontrolle zwei Teile je Tag, nacheinander: den Alpaca-Runner wie bisher und die IBKR-Instanz im Betriebsordner `/mnt/d/Claude/.arbeitsbaeume/trading-ibkr-betrieb`. Der IBKR-Teil hat eine eigene Ergebnisdatei (`data/paper/abendkontrollen/<tag>-ibkr.json` im Projekt), eine eigene Zeile „Abendkontrolle IBKR (automatisch)“ in derselben Tagesnotiz, eigenes Nachholen ab `IBKR_ERSTER_TAG` in `abendkontrolle.py` und bei Befund eine eigene Meldung mit hoher Priorität, Titel „IBKR Abendkontrolle <tag>: n Befund(e)“. Ist der Zeitgeber `trading-paper-ibkr.timer` nicht `active` und gibt es kein Protokoll des Tages (No-Go-Tag), steht nur „IBKR-Instanz heute nicht gestartet (Zeitgeber aus).“ da, ohne Befund.
|
||||||
|
|
||||||
|
| Datei | Was sich ändert | Aufruf |
|
||||||
|
|---|---|---|
|
||||||
|
| `abendkontrolle_wsl.py` | `--teil ibkr`: Kalender wie beim Alpaca-Teil; Protokolle `<tag>T*-paper.jsonl` im Betriebsordner (Ende je Lauf, mindestens ein Takt `aktiv`, Schlussposition jedes Laufs aus seiner Zeile `ende` unabhängig vom Journal); die Instanzmarke `data/paper/broker.txt` muss `ibkr` sagen; Register der Instanz nur lesend (`mode=ro`), `gesperrt` ist ein Befund mit Stufe und Auslöser des jüngsten Ereignisses; Journal von `trading-paper-ibkr.service` (Ende, Exit, `Failed with result`, `Traceback`, „Nichts wird gebaut“); werktags außerhalb 09:00 bis 16:15 New Yorker Zeit und am Wochenende `scripts/ibkr_pruefen.py lesen` im Betriebsordner mit `~/venvs/trading-ibkr` (Client-ID 12, lesend) — Positionen und offene Orders müssen 0 sein; im Handelsfenster nur ein Hinweis. Aus der Ausgabe von `ibkr_pruefen.py` gehen nur die Zahlen und die Zeilen `BEFUND` und „Voraussetzung fehlt“ weiter, Kontonummern als `[konto]`, dann durch `maskiere()` | in WSL: `python abendkontrolle_wsl.py --teil ibkr --tag JJJJ-MM-TT [--ohne-meldung]`; für die Angriffsfälle dazu `--ibkr-ordner`, `--ibkr-konto` (JSON mit `exit` und `text`), `--jetzt` (ISO mit Versatz), `--kalender`, `--journal`, `--dienste` |
|
||||||
|
| `abendkontrolle.py` | fährt ohne `--teil` beide Teile nacheinander, erst `alpaca`, dann `ibkr`; `--teil alpaca` oder `--teil ibkr` nur einen; mit `--ohne-wsl` ohne `--teil` nur `alpaca`. Ein Ergebnis, dessen `teil` nicht zum angeforderten passt, gilt als gescheitert. Die Befund-Zeile beider Teile baut dieselbe Funktion `_zeile` | `python abendkontrolle.py [--tag JJJJ-MM-TT] [--teil ibkr]` |
|
||||||
|
| `abendkontrolle_angriffe.py` | dazu IBKR-Fälle in WSL (Grundlinie, Zeitgeber aus und an, von Hand gelaufen, Kalender, Marke, Register, Ende je Lauf, Handel, Restposition im Protokoll bei sauberem Journal, Journal, Konto am Gateway samt Maske der Kontonummer, Handelsfenster, Ordner fehlt, Bedienfehler, Meldeweg gegen `ntfy_probe_server.py` mit Titel „IBKR …“ und Priorität `high`) und unter Windows (eigene Zeile und Datei, Befund, No-Go-Tag, kein Handelstag, Abbruch, gescheitert, Ergebnis eines anderen Teils, `--ohne-wsl` nur Alpaca, beide Teile in einer Notiz, fällige Tage relativ zu `IBKR_ERSTER_TAG`), ohne Netz | wie oben |
|
||||||
|
| `abendkontrolle_mutanten.py` | dazu Mutanten der IBKR-Teile beider Dateien, auch für Schlussposition, Titelpräfix, Meldeweiche und die Prüfung des Teils; die Anker „keine Ergebnisdatei“ und „Wochenende mit“ folgen dem Code aus IB2 | wie oben |
|
||||||
|
|
||||||
|
Die Zahlen der Fälle und Mutanten im Abschnitt vom 2026-09-24 gelten für den Stand vor IB2; wie viele es jetzt sind, sagt die Zeile `ERGEBNIS` des Laufs. Was der IBKR-Teil nicht sieht: einen Tagesbericht (die Instanz hat keine Tagesauswertung), `handelstag_auswertung.py` und den Dienst `ibkr-gateway.service`. Holt die Kontrolle einen Tag während der New Yorker Handelszeit nach (Anmeldung am Nachmittag), liest sie das Konto nicht; der nächste Abendlauf liest es für seinen eigenen Tag. Den Zeitgeber liest sie zum Zeitpunkt der Kontrolle, nicht für den geprüften Tag: Beim Nachholen nach einem Umschalten kann sie einen Ausfall als „nicht gestartet“ werten oder einen No-Go-Tag als Befund.
|
||||||
@@ -17,6 +17,11 @@ die mit "## Agentenprotokoll" beginnt.
|
|||||||
einer Zeile in lauf.log: Die Kontrolle ist gelaufen und hat ihre Meldung schon geschickt; ein zweiter
|
einer Zeile in lauf.log: Die Kontrolle ist gelaufen und hat ihre Meldung schon geschickt; ein zweiter
|
||||||
Lauf schickte sie nur noch einmal.
|
Lauf schickte sie nur noch einmal.
|
||||||
- Ein Tag, der laenger als NACHHOLEN_TAGE scheitert, faellt aus dem Fenster (lauf.log behaelt ihn).
|
- Ein Tag, der laenger als NACHHOLEN_TAGE scheitert, faellt aus dem Fenster (lauf.log behaelt ihn).
|
||||||
|
Seit Paket IB2 zwei Teile je Tag, nacheinander: alpaca wie oben, dann ibkr (abendkontrolle_wsl.py
|
||||||
|
--teil ibkr) mit eigener Ergebnisdatei <tag>-ibkr.json, eigener Zeile "Abendkontrolle IBKR" in
|
||||||
|
derselben Notiz und eigenem Nachholen ab IBKR_ERSTER_TAG. --teil nimmt nur einen Teil; mit
|
||||||
|
--ohne-wsl gilt ohne --teil nur alpaca (die Angriffsfaelle von vor IB2 bleiben gleich). Ein
|
||||||
|
Ergebnis, das zu einem anderen Teil gehoert, gilt als gescheitert.
|
||||||
Die Notiz wird ueber eine Nachbardatei und os.replace geschrieben: Ein Abbruch mitten im Schreiben
|
Die Notiz wird ueber eine Nachbardatei und os.replace geschrieben: Ein Abbruch mitten im Schreiben
|
||||||
hinterlaesst die alte Datei, nicht eine halbe. Gegen eine Claude-Sitzung, die dieselbe Notiz im selben
|
hinterlaesst die alte Datei, nicht eine halbe. Gegen eine Claude-Sitzung, die dieselbe Notiz im selben
|
||||||
Augenblick schreibt, schuetzt das nicht (Zeitfenster Lesen bis Ersetzen, Millisekunden) -- benannt.
|
Augenblick schreibt, schuetzt das nicht (Zeitfenster Lesen bis Ersetzen, Millisekunden) -- benannt.
|
||||||
@@ -50,25 +55,34 @@ FRUEHESTENS = time(22, 30)
|
|||||||
WSL_TIMEOUT_S = 1200
|
WSL_TIMEOUT_S = 1200
|
||||||
KEIN_FENSTER = 0x08000000 # CREATE_NO_WINDOW: pythonw hat keine Konsole, wsl.exe soll keine oeffnen
|
KEIN_FENSTER = 0x08000000 # CREATE_NO_WINDOW: pythonw hat keine Konsole, wsl.exe soll keine oeffnen
|
||||||
UEBERSCHRIFT = "## Agentenprotokoll"
|
UEBERSCHRIFT = "## Agentenprotokoll"
|
||||||
|
# Seit Paket IB2: die IBKR-Instanz als zweiter Teil mit eigener Ergebnisdatei und eigener Zeile.
|
||||||
|
TEILE = ("alpaca", "ibkr")
|
||||||
|
# Der erste Tag, den der Teil ibkr prueft; gesetzt beim Einspielen von IB2 (Planuebersicht IB2).
|
||||||
|
IBKR_ERSTER_TAG = date(2026, 9, 25)
|
||||||
|
IBKR_ENDUNG = "-ibkr"
|
||||||
|
KOEPFE = {
|
||||||
|
"alpaca": "Abendkontrolle Trading Plattform (automatisch)",
|
||||||
|
"ibkr": "Abendkontrolle IBKR (automatisch)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def faellige_tage(jetzt: datetime, ablage: Path) -> list[str]:
|
def faellige_tage(jetzt: datetime, ablage: Path, erster_tag: date = ERSTER_TAG, endung: str = "") -> list[str]:
|
||||||
tage = []
|
tage = []
|
||||||
for zurueck in range(NACHHOLEN_TAGE, -1, -1):
|
for zurueck in range(NACHHOLEN_TAGE, -1, -1):
|
||||||
tag = jetzt.date() - timedelta(days=zurueck)
|
tag = jetzt.date() - timedelta(days=zurueck)
|
||||||
if tag < ERSTER_TAG or tag.weekday() >= 5:
|
if tag < erster_tag or tag.weekday() >= 5:
|
||||||
continue
|
continue
|
||||||
if tag == jetzt.date() and jetzt.time() < FRUEHESTENS:
|
if tag == jetzt.date() and jetzt.time() < FRUEHESTENS:
|
||||||
continue
|
continue
|
||||||
if (ablage / f"{tag.isoformat()}.json").exists():
|
if (ablage / f"{tag.isoformat()}{endung}.json").exists():
|
||||||
continue
|
continue
|
||||||
tage.append(tag.isoformat())
|
tage.append(tag.isoformat())
|
||||||
return tage
|
return tage
|
||||||
|
|
||||||
|
|
||||||
def fahre_wsl(tag: str) -> dict:
|
def fahre_wsl(tag: str, teil: str = "alpaca") -> dict:
|
||||||
lauf = subprocess.run(
|
lauf = subprocess.run(
|
||||||
[WSL, "-d", "Debian", "-u", "claude", "--", WSL_PY, WSL_SKRIPT, "--tag", tag],
|
[WSL, "-d", "Debian", "-u", "claude", "--", WSL_PY, WSL_SKRIPT, "--tag", tag, "--teil", teil],
|
||||||
capture_output=True, timeout=WSL_TIMEOUT_S, creationflags=KEIN_FENSTER,
|
capture_output=True, timeout=WSL_TIMEOUT_S, creationflags=KEIN_FENSTER,
|
||||||
)
|
)
|
||||||
ausgabe = lauf.stdout.decode("utf-8", errors="replace")
|
ausgabe = lauf.stdout.decode("utf-8", errors="replace")
|
||||||
@@ -107,7 +121,11 @@ def zeile_fuer(ergebnis: dict, uhrzeit: str) -> str:
|
|||||||
killswitch = fakten.get("killswitch") or {}
|
killswitch = fakten.get("killswitch") or {}
|
||||||
if killswitch:
|
if killswitch:
|
||||||
teile.append(f"Killswitch {killswitch.get('zustand')} ({killswitch.get('ausloeser')})")
|
teile.append(f"Killswitch {killswitch.get('zustand')} ({killswitch.get('ausloeser')})")
|
||||||
zusammen = "; ".join(teile)
|
return _zeile(kopf, befunde, "; ".join(teile), fakten, hinweise)
|
||||||
|
|
||||||
|
|
||||||
|
def _zeile(kopf: str, befunde: list, zusammen: str, fakten: dict, hinweise: list) -> str:
|
||||||
|
"""Kopf, Urteil, Zusammenfassung, Befunde und Hinweise -- fuer beide Teile dieselbe Form."""
|
||||||
if not befunde:
|
if not befunde:
|
||||||
text = f"{kopf}: ohne Befund. {zusammen}."
|
text = f"{kopf}: ohne Befund. {zusammen}."
|
||||||
else:
|
else:
|
||||||
@@ -119,6 +137,38 @@ def zeile_fuer(ergebnis: dict, uhrzeit: str) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def zeile_fuer_ibkr(ergebnis: dict, uhrzeit: str) -> str:
|
||||||
|
"""Die eigene Zeile der IBKR-Instanz (seit Paket IB2). Kein Betrag, keine Kontonummer."""
|
||||||
|
kopf = f"- {uhrzeit} Uhr — {KOEPFE['ibkr']}"
|
||||||
|
befunde = ergebnis.get("befunde") or []
|
||||||
|
fakten = ergebnis.get("fakten") or {}
|
||||||
|
hinweise = ergebnis.get("hinweise") or []
|
||||||
|
if fakten.get("handelstag") is False and not befunde:
|
||||||
|
return f"{kopf}: kein Handelstag laut Alpaca-Kalender."
|
||||||
|
if fakten.get("nicht_gestartet") and not befunde:
|
||||||
|
return f"{kopf}: IBKR-Instanz heute nicht gestartet (Zeitgeber aus)."
|
||||||
|
teile = []
|
||||||
|
ende = fakten.get("ende") or {}
|
||||||
|
if ende:
|
||||||
|
teile.append(
|
||||||
|
f"Ende {ende.get('grund')}, Exit {ende.get('exit')}, Orders {ende.get('orders')}, "
|
||||||
|
f"Position {ende.get('position')}"
|
||||||
|
)
|
||||||
|
laeufe = fakten.get("laeufe") or []
|
||||||
|
if laeufe:
|
||||||
|
teile.append(f"{len(laeufe)} Lauf/Läufe, {sum(l.get('takte_aktiv', 0) for l in laeufe)} Takte aktiv")
|
||||||
|
konto = fakten.get("konto") or {}
|
||||||
|
if konto:
|
||||||
|
teile.append(
|
||||||
|
f"Konto beim Gateway: {konto.get('positionen')} Position(en), "
|
||||||
|
f"{konto.get('orders')} offene Order(s)"
|
||||||
|
)
|
||||||
|
killswitch = fakten.get("killswitch") or {}
|
||||||
|
if killswitch:
|
||||||
|
teile.append(f"Killswitch {killswitch.get('zustand')} ({killswitch.get('ausloeser')})")
|
||||||
|
return _zeile(kopf, befunde, "; ".join(teile), fakten, hinweise)
|
||||||
|
|
||||||
|
|
||||||
def schreibe_notiz(vault: Path, tag: str, zeile: str) -> None:
|
def schreibe_notiz(vault: Path, tag: str, zeile: str) -> None:
|
||||||
notiz = vault / "Inbox" / f"{tag}.md"
|
notiz = vault / "Inbox" / f"{tag}.md"
|
||||||
if notiz.exists():
|
if notiz.exists():
|
||||||
@@ -167,47 +217,81 @@ def main(argv=None) -> int:
|
|||||||
zerleger.add_argument("--vault", default=str(VAULT))
|
zerleger.add_argument("--vault", default=str(VAULT))
|
||||||
zerleger.add_argument("--ohne-wsl", help="Ergebnis aus einer JSON-Datei statt aus WSL (Angriffsfaelle)")
|
zerleger.add_argument("--ohne-wsl", help="Ergebnis aus einer JSON-Datei statt aus WSL (Angriffsfaelle)")
|
||||||
zerleger.add_argument("--jetzt", help="ISO-Zeitpunkt statt der Uhr (Angriffsfaelle)")
|
zerleger.add_argument("--jetzt", help="ISO-Zeitpunkt statt der Uhr (Angriffsfaelle)")
|
||||||
|
zerleger.add_argument("--teil", choices=TEILE, help="nur diesen Teil; ohne Angabe beide, mit --ohne-wsl nur alpaca")
|
||||||
argumente = zerleger.parse_args(argv)
|
argumente = zerleger.parse_args(argv)
|
||||||
vault = Path(argumente.vault)
|
vault = Path(argumente.vault)
|
||||||
ablage = vault / ABLAGE_TEIL
|
ablage = vault / ABLAGE_TEIL
|
||||||
jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now()
|
jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now()
|
||||||
tage = [argumente.tag] if argumente.tag else faellige_tage(jetzt, ablage)
|
# Ohne --teil beide Teile, erst alpaca, dann ibkr. Mit --ohne-wsl ohne --teil nur alpaca: Eine
|
||||||
logge(ablage, f"start, faellig: {tage}")
|
# Ergebnisdatei ist immer die eines Teils, und die Angriffsfaelle von vor IB2 bleiben gleich.
|
||||||
|
teile = [argumente.teil] if argumente.teil else (["alpaca"] if argumente.ohne_wsl else list(TEILE))
|
||||||
|
code = 0
|
||||||
|
for teil in teile:
|
||||||
|
if argumente.tag:
|
||||||
|
tage = [argumente.tag]
|
||||||
|
elif teil == "ibkr":
|
||||||
|
tage = faellige_tage(jetzt, ablage, erster_tag=IBKR_ERSTER_TAG, endung=IBKR_ENDUNG)
|
||||||
|
else:
|
||||||
|
tage = faellige_tage(jetzt, ablage)
|
||||||
|
vorsatz = "" if teil == "alpaca" else f"{teil} "
|
||||||
|
# Scheitert ein Teil ausserhalb seiner eigenen Fehlerbehandlung (Ergebnisdatei nicht
|
||||||
|
# schreibbar, lauf.log gesperrt), laeuft der naechste trotzdem (Pruefung IB2).
|
||||||
|
try:
|
||||||
|
logge(ablage, f"{vorsatz}start, faellig: {tage}")
|
||||||
|
code = max(code, fahre_teil(teil, tage, jetzt, vault, ablage, argumente.ohne_wsl))
|
||||||
|
except Exception as ausnahme:
|
||||||
|
code = 1
|
||||||
|
try:
|
||||||
|
logge(ablage, f"{vorsatz}abgebrochen: {type(ausnahme).__name__}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def fahre_teil(teil: str, tage: list[str], jetzt: datetime, vault: Path, ablage: Path, ohne_wsl: str | None) -> int:
|
||||||
|
"""Ein Teil fuer alle faelligen Tage: Ergebnisdatei, Zeile in der Notiz, lauf.log."""
|
||||||
|
endung = IBKR_ENDUNG if teil == "ibkr" else ""
|
||||||
|
zeile_von = zeile_fuer_ibkr if teil == "ibkr" else zeile_fuer
|
||||||
|
vorsatz = "" if teil == "alpaca" else f"{teil} "
|
||||||
code = 0
|
code = 0
|
||||||
for tag in tage:
|
for tag in tage:
|
||||||
uhrzeit = jetzt.strftime("%H:%M") if tag == jetzt.date().isoformat() else f"{jetzt:%Y-%m-%d %H:%M}"
|
uhrzeit = jetzt.strftime("%H:%M") if tag == jetzt.date().isoformat() else f"{jetzt:%Y-%m-%d %H:%M}"
|
||||||
try:
|
try:
|
||||||
if argumente.ohne_wsl:
|
if ohne_wsl:
|
||||||
ergebnis = json.loads(Path(argumente.ohne_wsl).read_text(encoding="utf-8"))
|
ergebnis = json.loads(Path(ohne_wsl).read_text(encoding="utf-8"))
|
||||||
else:
|
else:
|
||||||
ergebnis = fahre_wsl(tag)
|
ergebnis = fahre_wsl(tag, teil)
|
||||||
if not isinstance(ergebnis, dict) or not isinstance(ergebnis.get("befunde"), list):
|
if not isinstance(ergebnis, dict) or not isinstance(ergebnis.get("befunde"), list):
|
||||||
raise RuntimeError("Ergebnis des WSL-Teils in unerwarteter Form")
|
raise RuntimeError("Ergebnis des WSL-Teils in unerwarteter Form")
|
||||||
|
# Ein Ergebnis ohne teil stammt von einem WSL-Teil vor IB2 und gilt als alpaca. Passt
|
||||||
|
# der Teil nicht, landete es sonst still in der falschen Ergebnisdatei und Zeile.
|
||||||
|
if ergebnis.get("teil", "alpaca") != teil:
|
||||||
|
raise RuntimeError(f"Ergebnis des WSL-Teils gehoert zu {ergebnis.get('teil')!r}, nicht zu {teil!r}")
|
||||||
except Exception as ausnahme:
|
except Exception as ausnahme:
|
||||||
code = 1
|
code = 1
|
||||||
logge(ablage, f"{tag}: gescheitert {ausnahme!r}\n{traceback.format_exc()}")
|
logge(ablage, f"{vorsatz}{tag}: gescheitert {ausnahme!r}\n{traceback.format_exc()}")
|
||||||
try:
|
try:
|
||||||
schreibe_notiz(
|
schreibe_notiz(
|
||||||
vault, tag,
|
vault, tag,
|
||||||
f"- {uhrzeit} Uhr — Abendkontrolle Trading Plattform (automatisch): **gescheitert** "
|
f"- {uhrzeit} Uhr — {KOEPFE[teil]}: **gescheitert** "
|
||||||
f"({type(ausnahme).__name__}: {str(ausnahme)[:200]}) #needs-review. Der nächste Lauf versucht es erneut.",
|
f"({type(ausnahme).__name__}: {str(ausnahme)[:200]}) #needs-review. Der nächste Lauf versucht es erneut.",
|
||||||
)
|
)
|
||||||
except Exception as zweite:
|
except Exception as zweite:
|
||||||
logge(ablage, f"{tag}: auch die Notiz scheiterte {zweite!r}")
|
logge(ablage, f"{vorsatz}{tag}: auch die Notiz scheiterte {zweite!r}")
|
||||||
continue
|
continue
|
||||||
# Ein Abbruch im WSL-Teil (Exit 2) wird wiederholt; alles andere gilt als gelaufen.
|
# Ein Abbruch im WSL-Teil (Exit 2) wird wiederholt; alles andere gilt als gelaufen.
|
||||||
if ergebnis.get("exit") != 2:
|
if ergebnis.get("exit") != 2:
|
||||||
(ablage / f"{tag}.json").write_text(
|
(ablage / f"{tag}{endung}.json").write_text(
|
||||||
json.dumps(ergebnis, ensure_ascii=False, indent=1), encoding="utf-8", newline="\n"
|
json.dumps(ergebnis, ensure_ascii=False, indent=1), encoding="utf-8", newline="\n"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
code = 1
|
code = 1
|
||||||
logge(ablage, f"{tag}: {len(ergebnis['befunde'])} Befund(e), exit {ergebnis.get('exit')}")
|
logge(ablage, f"{vorsatz}{tag}: {len(ergebnis['befunde'])} Befund(e), exit {ergebnis.get('exit')}")
|
||||||
try:
|
try:
|
||||||
schreibe_notiz(vault, tag, zeile_fuer(ergebnis, uhrzeit))
|
schreibe_notiz(vault, tag, zeile_von(ergebnis, uhrzeit))
|
||||||
except Exception as ausnahme:
|
except Exception as ausnahme:
|
||||||
code = 1
|
code = 1
|
||||||
logge(ablage, f"{tag}: Notiz nicht geschrieben {ausnahme!r}")
|
logge(ablage, f"{vorsatz}{tag}: Notiz nicht geschrieben {ausnahme!r}")
|
||||||
return code
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -397,10 +397,368 @@ def windows_faelle() -> None:
|
|||||||
abweichungen.append("Nachholfenster")
|
abweichungen.append("Nachholfenster")
|
||||||
|
|
||||||
|
|
||||||
|
# --- IBKR-Instanz (Paket IB2), WSL-Teil ------------------------------------------------------------
|
||||||
|
|
||||||
|
IBKR_JOURNAL_GUT = (
|
||||||
|
"Ende: boersenschluss, Position 0, Orders 2, Killswitch aktiv, Exit 0\n"
|
||||||
|
"trading-paper-ibkr.service: Deactivated successfully.\n"
|
||||||
|
)
|
||||||
|
# Erfundene Ausgabe von scripts/ibkr_pruefen.py lesen: ohne Kontonummer, ohne Betrag.
|
||||||
|
IBKR_KONTO_GUT = (
|
||||||
|
"verbunden: Gateway 127.0.0.1:4004, Client-ID 12, Handelsmodus paper, Konto DU... (9 Zeichen), "
|
||||||
|
"vom Gateway bestaetigt\n"
|
||||||
|
"Konto: Kontowert, Bargeld und Kaufkraft gelesen (Betraege werden nicht ausgegeben)\n"
|
||||||
|
"Waehrung des Kontowerts: EUR\n"
|
||||||
|
"Positionen: 0\n"
|
||||||
|
"Offene Orders: 0\n"
|
||||||
|
"ERGEBNIS: gelesen\n"
|
||||||
|
)
|
||||||
|
# Eine erfundene Kontonummer in der Form der Paper-Konten, nur fuer die Maske.
|
||||||
|
KONTO_ERFUNDEN = "DU0000000"
|
||||||
|
KONTO_ERFUNDEN_LANG = "DUQ000000"
|
||||||
|
# 22:45 Berlin ist 16:45 New York (nach Boersenschluss), 16:00 Berlin ist 10:00 New York.
|
||||||
|
NACH_SCHLUSS = f"{TAG}T22:45:00+02:00"
|
||||||
|
IM_HANDEL = f"{TAG}T16:00:00+02:00"
|
||||||
|
|
||||||
|
|
||||||
|
def baue_ibkr(wurzel: Path) -> Path:
|
||||||
|
ordner = wurzel / "betrieb"
|
||||||
|
(ordner / "artifacts" / "paper").mkdir(parents=True)
|
||||||
|
(ordner / "data" / "paper").mkdir(parents=True)
|
||||||
|
(ordner / "data" / "paper" / "broker.txt").write_text("ibkr\n", encoding="utf-8")
|
||||||
|
shutil.copyfile(ECHTES_PROTOKOLL, ordner / "artifacts" / "paper" / f"{TAG}T131023-paper.jsonl")
|
||||||
|
with sqlite3.connect(ordner / "data" / "registry.db") as db:
|
||||||
|
db.execute("CREATE TABLE killswitch_zustand (id INTEGER PRIMARY KEY, zustand TEXT, seit TEXT, ausloeser TEXT)")
|
||||||
|
db.execute("CREATE TABLE killswitch_ereignisse (id INTEGER PRIMARY KEY AUTOINCREMENT, zeit TEXT, stufe TEXT, "
|
||||||
|
"ausloeser TEXT, grund TEXT, ergebnis TEXT, erfolgreich INTEGER)")
|
||||||
|
db.execute("INSERT INTO killswitch_zustand VALUES (1, 'aktiv', '2026-09-23T13:31:00+00:00', 'automatik')")
|
||||||
|
db.execute("INSERT INTO killswitch_ereignisse (zeit, stufe, ausloeser, grund, ergebnis, erfolgreich) "
|
||||||
|
"VALUES ('2026-09-23T13:31:00+00:00', 'fortsetzen', 'automatik', 'Tagesstart', '{}', 1)")
|
||||||
|
(wurzel / "kalender.json").write_text(json.dumps(KALENDER_TAG), encoding="utf-8")
|
||||||
|
(wurzel / "journal.txt").write_text(IBKR_JOURNAL_GUT, encoding="utf-8")
|
||||||
|
(wurzel / "dienste.json").write_text(json.dumps({"trading-paper-ibkr.timer": "active"}), encoding="utf-8")
|
||||||
|
(wurzel / "konto.json").write_text(json.dumps({"exit": 0, "text": IBKR_KONTO_GUT}), encoding="utf-8")
|
||||||
|
return ordner
|
||||||
|
|
||||||
|
|
||||||
|
def fahre_ibkr(wurzel: Path, ordner: Path, jetzt: str = NACH_SCHLUSS, tag: str = TAG, *,
|
||||||
|
projekt: Path | None = None, meldung: bool = False, env: dict | None = None) -> dict:
|
||||||
|
befehl = [
|
||||||
|
sys.executable, str(HIER / "abendkontrolle_wsl.py"), "--teil", "ibkr", "--tag", tag,
|
||||||
|
"--ibkr-ordner", str(ordner), "--projekt", str(wurzel if projekt is None else projekt),
|
||||||
|
"--kalender", str(wurzel / "kalender.json"), "--journal", str(wurzel / "journal.txt"),
|
||||||
|
"--dienste", str(wurzel / "dienste.json"), "--ibkr-konto", str(wurzel / "konto.json"), "--jetzt", jetzt,
|
||||||
|
]
|
||||||
|
if not meldung:
|
||||||
|
befehl.append("--ohne-meldung")
|
||||||
|
umgebung = {k: v for k, v in os.environ.items() if k != "ABENDKONTROLLE_AUSWERTUNG"}
|
||||||
|
umgebung.update(env or {})
|
||||||
|
lauf = subprocess.run(befehl, capture_output=True, text=True, env=umgebung, timeout=300)
|
||||||
|
try:
|
||||||
|
ergebnis = json.loads(lauf.stdout.strip().splitlines()[-1])
|
||||||
|
except (IndexError, json.JSONDecodeError):
|
||||||
|
return {"befunde": [f"KEINE JSON-AUSGABE exit {lauf.returncode}: {lauf.stderr[-300:]}"], "exit": lauf.returncode}
|
||||||
|
ergebnis["_exit"] = lauf.returncode
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def ibkr_wsl_faelle() -> None:
|
||||||
|
def fall(name, vorbereiten, *, befund, anzahl=None, exit_code=0, jetzt=NACH_SCHLUSS, tag=TAG,
|
||||||
|
auch=(), nicht=(), fakt=None):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
wurzel = Path(tmp)
|
||||||
|
ordner = baue_ibkr(wurzel)
|
||||||
|
vorbereiten(wurzel, ordner)
|
||||||
|
erwarte("IBKR " + name, fahre_ibkr(wurzel, ordner, jetzt, tag), befund=befund, anzahl=anzahl,
|
||||||
|
exit_code=exit_code, auch=auch, nicht=nicht, fakt=fakt)
|
||||||
|
|
||||||
|
nichts = lambda w, o: None # noqa: E731
|
||||||
|
prot = lambda o: o / "artifacts" / "paper" / f"{TAG}T131023-paper.jsonl" # noqa: E731
|
||||||
|
|
||||||
|
def schreibe(pfad: Path, text: str) -> None:
|
||||||
|
pfad.write_text(text, encoding="utf-8")
|
||||||
|
|
||||||
|
def konto(text: str, code: int = 0):
|
||||||
|
return lambda w, o: schreibe(w / "konto.json", json.dumps({"exit": code, "text": text}))
|
||||||
|
|
||||||
|
def register(*befehle: str):
|
||||||
|
def vorbereiten(w, o):
|
||||||
|
with sqlite3.connect(o / "data" / "registry.db") as db:
|
||||||
|
for befehl in befehle:
|
||||||
|
db.execute(befehl)
|
||||||
|
return vorbereiten
|
||||||
|
|
||||||
|
def zeilen_aendern(o: Path, aendern) -> None:
|
||||||
|
zeilen = prot(o).read_text(encoding="utf-8").splitlines()
|
||||||
|
schreibe(prot(o), "\n".join(aendern(zeilen)) + "\n")
|
||||||
|
|
||||||
|
fall("Grundlinie", nichts, befund=None, anzahl=0,
|
||||||
|
fakt=lambda f, e: f.get("konto") == {"positionen": 0, "orders": 0, "exit": 0} and e.get("teil") == "ibkr")
|
||||||
|
# I2 No-Go-Tag: Zeitgeber aus und kein Protokoll ist leise; Zeitgeber an und kein Protokoll ist ein Befund.
|
||||||
|
def zeitgeber_aus(w, o):
|
||||||
|
prot(o).unlink()
|
||||||
|
schreibe(w / "dienste.json", json.dumps({"trading-paper-ibkr.timer": "inactive"}))
|
||||||
|
fall("Zeitgeber aus, kein Protokoll", zeitgeber_aus, befund=None, anzahl=0,
|
||||||
|
fakt=lambda f, e: f.get("nicht_gestartet") is True
|
||||||
|
and any("IBKR-Instanz heute nicht gestartet" in h for h in e.get("hinweise", [])))
|
||||||
|
fall("Zeitgeber an, kein Protokoll", lambda w, o: prot(o).unlink(), befund="die Instanz lief nicht")
|
||||||
|
fall("Zeitgeber aus, aber von Hand gelaufen",
|
||||||
|
lambda w, o: schreibe(w / "dienste.json", json.dumps({"trading-paper-ibkr.timer": "inactive"})),
|
||||||
|
befund=None, anzahl=0, fakt=lambda f, e: len(f.get("laeufe") or []) == 1 and not f.get("nicht_gestartet"))
|
||||||
|
fall("Kalender leer", lambda w, o: (schreibe(w / "kalender.json", "[]"), prot(o).unlink()), befund=None, anzahl=0)
|
||||||
|
# I3 Instanzmarke
|
||||||
|
fall("Marke fehlt", lambda w, o: (o / "data" / "paper" / "broker.txt").unlink(), befund="Instanzmarke")
|
||||||
|
fall("Marke alpaca", lambda w, o: schreibe(o / "data" / "paper" / "broker.txt", "alpaca\n"),
|
||||||
|
befund="nicht der Betriebsordner der IBKR-Instanz")
|
||||||
|
# I4 Register
|
||||||
|
fall("gesperrt nach Red Button",
|
||||||
|
register("UPDATE killswitch_zustand SET zustand = 'gesperrt', ausloeser = 'pascal'",
|
||||||
|
"INSERT INTO killswitch_ereignisse (zeit, stufe, ausloeser, grund, ergebnis, erfolgreich) "
|
||||||
|
"VALUES ('2026-09-23T15:00:00+00:00', 'glattstellen', 'pascal', 'Probe', '{}', 1)"),
|
||||||
|
befund="IBKR-Killswitch steht auf gesperrt", auch=("letztes Ereignis glattstellen von pascal",))
|
||||||
|
fall("gesperrt ohne Ereignis",
|
||||||
|
register("UPDATE killswitch_zustand SET zustand = 'gesperrt'", "DELETE FROM killswitch_ereignisse"),
|
||||||
|
befund="IBKR-Killswitch steht auf gesperrt", auch=("ohne Ereignis im Register",))
|
||||||
|
fall("Killswitch unbekannter Zustand", register("UPDATE killswitch_zustand SET zustand = 'kaputt'"),
|
||||||
|
befund="IBKR-Killswitch in unbekanntem Zustand")
|
||||||
|
fall("Register fehlt", lambda w, o: (o / "data" / "registry.db").unlink(), befund="IBKR-Register data/registry.db fehlt")
|
||||||
|
fall("Register ohne Ereignistabelle", register("DROP TABLE killswitch_ereignisse"),
|
||||||
|
befund="IBKR-Killswitch nicht geprueft")
|
||||||
|
# I5 Ende je Lauf, Handel, Schlussposition
|
||||||
|
fall("Lauf endete mit abbruch",
|
||||||
|
lambda w, o: zeilen_aendern(o, lambda z: [s.replace('"grund": "boersenschluss"', '"grund": "abbruch"')
|
||||||
|
if '"art": "ende"' in s else s for s in z]),
|
||||||
|
befund="Lauf endete mit abbruch")
|
||||||
|
fall("nie aktiv",
|
||||||
|
lambda w, o: zeilen_aendern(o, lambda z: [s.replace('"killswitch": "aktiv"', '"killswitch": "angehalten"') for s in z]),
|
||||||
|
befund="Tag ohne Handel")
|
||||||
|
# Das Journal meldet Position 0, das Protokoll eine Restposition: Die Schlussposition kommt aus dem
|
||||||
|
# Protokoll, unabhaengig vom Journal (Anforderung Nr. 5, "Position 0").
|
||||||
|
fall("Restposition im Protokoll bei sauberem Journal",
|
||||||
|
lambda w, o: zeilen_aendern(o, lambda z: [s.replace('"position": 0', '"position": 10')
|
||||||
|
if '"art": "ende"' in s else s for s in z]),
|
||||||
|
befund="endete laut Protokoll mit Position 10", anzahl=1)
|
||||||
|
# Ein Lauf ohne Zeile ende: pruefe_laeufe ueberspringt ihn, im Teil ibkr meldet ihn
|
||||||
|
# pruefe_schlusspositionen (Nachkritik IB2).
|
||||||
|
fall("Lauf ohne Zeile ende",
|
||||||
|
lambda w, o: zeilen_aendern(o, lambda z: [s for s in z if '"art": "ende"' not in s]),
|
||||||
|
befund="Lauf ohne Zeile ende im Protokoll")
|
||||||
|
# I6 Journal
|
||||||
|
fall("Journal Exit 4", lambda w, o: schreibe(w / "journal.txt", IBKR_JOURNAL_GUT.replace("Exit 0", "Exit 4")),
|
||||||
|
befund="Exit 4")
|
||||||
|
fall("Journal ohne Ende", lambda w, o: schreibe(w / "journal.txt", "gestartet\n"),
|
||||||
|
befund="von trading-paper-ibkr.service an diesem Tag")
|
||||||
|
fall("Journal Failed with result",
|
||||||
|
lambda w, o: schreibe(w / "journal.txt", IBKR_JOURNAL_GUT + "trading-paper-ibkr.service: Failed with result 'exit-code'.\n"),
|
||||||
|
befund="Journal: trading-paper-ibkr.service endete mit 'Failed with result'")
|
||||||
|
fall("Journal Traceback",
|
||||||
|
lambda w, o: schreibe(w / "journal.txt", IBKR_JOURNAL_GUT + "Traceback (most recent call last):\n File x\nValueError: y\n"),
|
||||||
|
befund="zeigt 'Traceback")
|
||||||
|
fall("Journal Marke passt nicht zur Konfiguration",
|
||||||
|
lambda w, o: schreibe(w / "journal.txt", IBKR_JOURNAL_GUT + "Dieser Ordner ist die IBKR-Instanz, die Konfiguration "
|
||||||
|
"waehlt aber [broker] wahl = 'alpaca'. Nichts wird gebaut, das Alpaca-Konto bleibt unberuehrt.\n"),
|
||||||
|
befund="zeigt 'Nichts wird gebaut'")
|
||||||
|
# I7 Konto am Gateway
|
||||||
|
fall("Konto 2 Positionen",
|
||||||
|
konto(IBKR_KONTO_GUT.replace("Positionen: 0\n", "Positionen: 2\n AAPL.XNAS 10\n KO.XNYS 10\n")),
|
||||||
|
befund="Beim IBKR-Gateway stehen 2 Position(en)", nicht=("AAPL.XNAS 10", "KO.XNYS 10"))
|
||||||
|
fall("Konto 1 offene Order", konto(IBKR_KONTO_GUT.replace("Offene Orders: 0", "Offene Orders: 1")),
|
||||||
|
befund="Beim IBKR-Gateway stehen 1 offene Order(s)")
|
||||||
|
fall("Konto ohne Zeilen", konto("verbunden: Gateway 127.0.0.1:4004\nERGEBNIS: gelesen\n"),
|
||||||
|
befund="Konto am Gateway nicht gelesen")
|
||||||
|
fall("Konto Voraussetzung fehlt", konto("Voraussetzung fehlt: config/paper-ibkr.toml fehlt\n", 4),
|
||||||
|
befund="endete mit Exit 4", auch=("Voraussetzung fehlt",))
|
||||||
|
fall("Konto BEFUND mit Kontonummer wird maskiert",
|
||||||
|
konto(IBKR_KONTO_GUT + f"BEFUND: Gateway meldet das Konto {KONTO_ERFUNDEN} als gesperrt\n", 1),
|
||||||
|
befund="[konto]", nicht=(KONTO_ERFUNDEN,))
|
||||||
|
# Die echte Form hat drei Buchstaben vorn und sechs Ziffern (Pruefung IB2); erfunden, wie oben.
|
||||||
|
fall("Konto BEFUND mit Kontonummer in der echten Form wird maskiert",
|
||||||
|
konto(IBKR_KONTO_GUT + f"BEFUND: Gateway meldet das Konto {KONTO_ERFUNDEN_LANG} als gesperrt\n", 1),
|
||||||
|
befund="[konto]", nicht=(KONTO_ERFUNDEN_LANG,))
|
||||||
|
fall("im Handelsfenster wird das Konto nicht gelesen", konto("kaputt", 9), befund=None, anzahl=0, jetzt=IM_HANDEL,
|
||||||
|
fakt=lambda f, e: "konto" not in f and any("nicht gelesen" in h for h in e.get("hinweise", [])))
|
||||||
|
# Bedienung
|
||||||
|
fall("Betriebsordner fehlt", lambda w, o: shutil.rmtree(o), befund="Betriebsordner der IBKR-Instanz fehlt", anzahl=1)
|
||||||
|
fall("Tag falsch", nichts, befund="abgebrochen", exit_code=2, tag="2026-13-40")
|
||||||
|
|
||||||
|
# Meldeweg des Teils ibkr ohne --ohne-meldung, gegen den lokalen Probeserver: Titel beginnt mit
|
||||||
|
# "IBKR Abendkontrolle", Prioritaet hoch (Anforderung Nr. 5). Eine Zeile kennung in der kopierten
|
||||||
|
# config/paper.toml faellt heraus, sonst stuende sie vor dem Titel.
|
||||||
|
with tempfile.TemporaryDirectory() as tmp_server:
|
||||||
|
mitschrift = Path(tmp_server) / "mitschrift.jsonl"
|
||||||
|
port = 18766
|
||||||
|
server = subprocess.Popen([sys.executable, str(HIER / "ntfy_probe_server.py"), str(port), str(mitschrift)],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
try:
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
for _ in range(50):
|
||||||
|
try:
|
||||||
|
socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
time.sleep(0.1)
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
wurzel = Path(tmp)
|
||||||
|
projekt = baue_projekt(wurzel)
|
||||||
|
ordner = baue_ibkr(wurzel)
|
||||||
|
(ordner / "data" / "paper" / "broker.txt").unlink() # ein Befund, damit gemeldet wird
|
||||||
|
text = (ECHTES_PROJEKT / "config" / "paper.toml").read_text(encoding="utf-8")
|
||||||
|
anker = 'ntfy_url = "https://ntfy.sh"'
|
||||||
|
if text.count(anker) != 1:
|
||||||
|
raise RuntimeError("Anker ntfy_url fehlt in config/paper.toml")
|
||||||
|
text = text.replace(anker, f'ntfy_url = "http://127.0.0.1:{port}"\nklartext_lokal = true')
|
||||||
|
text = "\n".join(z for z in text.split("\n") if not z.strip().startswith("kennung"))
|
||||||
|
schreibe(projekt / "config" / "paper.toml", text)
|
||||||
|
schreibe(wurzel / "secrets.toml", '[ntfy]\naktiv = true\nthema = "abendkontrolle-angriff"\n')
|
||||||
|
ergebnis = fahre_ibkr(wurzel, ordner, projekt=projekt, meldung=True,
|
||||||
|
env={"TRADING_GEHEIMNISSE": str(wurzel / "secrets.toml")})
|
||||||
|
zeilen = ([json.loads(z) for z in mitschrift.read_text(encoding="utf-8").split("\n") if z.strip()]
|
||||||
|
if mitschrift.exists() else [])
|
||||||
|
titel = [str(z.get("title") or "") for z in zeilen]
|
||||||
|
stufen = [z.get("priority") for z in zeilen]
|
||||||
|
gesendet = (ergebnis.get("fakten") or {}).get("meldung")
|
||||||
|
ok = (gesendet == "gesendet" and len(zeilen) == 1 and titel[0].startswith("IBKR Abendkontrolle")
|
||||||
|
and stufen == ["high"])
|
||||||
|
zeile = (f"{'OK ' if ok else 'FEHL'} IBKR Meldung mit Titel IBKR und Prioritaet hoch: "
|
||||||
|
f"titel={titel} prioritaet={stufen} meldung={gesendet}")
|
||||||
|
print(zeile)
|
||||||
|
if not ok:
|
||||||
|
abweichungen.append(zeile)
|
||||||
|
finally:
|
||||||
|
server.terminate()
|
||||||
|
server.wait(timeout=10)
|
||||||
|
|
||||||
|
|
||||||
|
# --- IBKR-Instanz (Paket IB2), Windows-Teil ----------------------------------------------------------
|
||||||
|
|
||||||
|
def ibkr_windows_faelle() -> None:
|
||||||
|
sys.path.insert(0, str(HIER))
|
||||||
|
import abendkontrolle as ak
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
gut = {"tag": TAG, "teil": "ibkr", "befunde": [], "hinweise": [], "exit": 0,
|
||||||
|
"fakten": {"handelstag": True,
|
||||||
|
"ende": {"grund": "boersenschluss", "exit": "0", "orders": "40", "position": "0"},
|
||||||
|
"laeufe": [{"takte_aktiv": 375}], "konto": {"positionen": 0, "orders": 0, "exit": 0},
|
||||||
|
"killswitch": {"zustand": "aktiv", "ausloeser": "automatik"}}}
|
||||||
|
alpaca = {"tag": TAG, "befunde": [], "hinweise": [], "exit": 0,
|
||||||
|
"fakten": {"handelstag": True,
|
||||||
|
"ende": {"grund": "boersenschluss", "exit": "0", "orders": "79", "position": "0"},
|
||||||
|
"laeufe": [{"takte_aktiv": 375}], "broker_offen": 0, "bericht": True,
|
||||||
|
"killswitch": {"zustand": "aktiv", "ausloeser": "automatik"}}}
|
||||||
|
|
||||||
|
def pruefe_fall(name, ok, grund):
|
||||||
|
zeile = f"{'OK ' if ok else 'FEHL'} W IBKR {name}: {grund}"
|
||||||
|
print(zeile)
|
||||||
|
if not ok:
|
||||||
|
abweichungen.append(zeile)
|
||||||
|
|
||||||
|
def lauf(name, ergebnis, *, teil="ibkr", pruefe):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
vault = Path(tmp)
|
||||||
|
(vault / "Inbox").mkdir()
|
||||||
|
eingabe = vault / "ergebnis.json"
|
||||||
|
eingabe.write_text(ergebnis if isinstance(ergebnis, str) else json.dumps(ergebnis), encoding="utf-8")
|
||||||
|
argumente = ["--vault", str(vault), "--tag", TAG, "--ohne-wsl", str(eingabe), "--jetzt", "2026-09-23T22:45:00"]
|
||||||
|
if teil is not None:
|
||||||
|
argumente += ["--teil", teil]
|
||||||
|
code = ak.main(argumente)
|
||||||
|
notiz = vault / "Inbox" / f"{TAG}.md"
|
||||||
|
text = notiz.read_text(encoding="utf-8") if notiz.exists() else ""
|
||||||
|
eigene = (vault / ak.ABLAGE_TEIL / f"{TAG}-ibkr.json").exists()
|
||||||
|
fremde = (vault / ak.ABLAGE_TEIL / f"{TAG}.json").exists()
|
||||||
|
try:
|
||||||
|
ok, grund = pruefe(code, text, eigene, fremde)
|
||||||
|
except Exception as ausnahme: # der Treiber selbst bricht nicht ab
|
||||||
|
ok, grund = False, f"Pruefung warf {ausnahme!r}"
|
||||||
|
pruefe_fall(name, ok, grund)
|
||||||
|
|
||||||
|
lauf("ohne Befund: eigene Zeile, eigene Datei", gut, pruefe=lambda c, t, e, f: (
|
||||||
|
c == 0 and e and not f and "— Abendkontrolle IBKR (automatisch): ohne Befund" in t
|
||||||
|
and "Konto beim Gateway: 0 Position(en), 0 offene Order(s)" in t
|
||||||
|
and "Abendkontrolle Trading Plattform" not in t, f"code {c} eigene {e} fremde {f} {t[-160:]!r}"))
|
||||||
|
lauf("mit Befund", {**gut, "befunde": ["Beim IBKR-Gateway stehen 2 Position(en)"]}, pruefe=lambda c, t, e, f: (
|
||||||
|
"Abendkontrolle IBKR (automatisch): **1 Befund(e)** #needs-review" in t
|
||||||
|
and " - Beim IBKR-Gateway stehen 2 Position(en)" in t and e, repr(t[-200:])))
|
||||||
|
lauf("nicht gestartet ist leise",
|
||||||
|
{"tag": TAG, "teil": "ibkr", "befunde": [], "exit": 0,
|
||||||
|
"hinweise": ["IBKR-Instanz heute nicht gestartet (trading-paper-ibkr.timer: inactive)"],
|
||||||
|
"fakten": {"handelstag": True, "nicht_gestartet": True, "zeitgeber": "inactive"}},
|
||||||
|
pruefe=lambda c, t, e, f: ("Abendkontrolle IBKR (automatisch): IBKR-Instanz heute nicht gestartet (Zeitgeber aus)." in t
|
||||||
|
and "#needs-review" not in t and e and c == 0, repr(t[-160:])))
|
||||||
|
lauf("kein Handelstag", {"tag": TAG, "teil": "ibkr", "befunde": [], "hinweise": [], "exit": 0,
|
||||||
|
"fakten": {"handelstag": False}},
|
||||||
|
pruefe=lambda c, t, e, f: ("Abendkontrolle IBKR (automatisch): kein Handelstag" in t and e, repr(t[-120:])))
|
||||||
|
lauf("Abbruch wird wiederholt", {"tag": TAG, "teil": "ibkr", "befunde": ["Abendkontrolle abgebrochen: X"],
|
||||||
|
"hinweise": [], "fakten": {}, "exit": 2},
|
||||||
|
pruefe=lambda c, t, e, f: (c == 1 and not e and "**1 Befund(e)**" in t, f"code {c} eigene {e}"))
|
||||||
|
lauf("gescheitert", "nicht json", pruefe=lambda c, t, e, f: (
|
||||||
|
c == 1 and not e and "Abendkontrolle IBKR (automatisch): **gescheitert**" in t, f"code {c} {t[-120:]!r}"))
|
||||||
|
# Ein Ergebnis des falschen Teils (etwa von einem WSL-Teil ohne IB2) landet nicht in der Datei der Instanz.
|
||||||
|
lauf("Ergebnis eines anderen Teils", {**gut, "teil": "alpaca"}, pruefe=lambda c, t, e, f: (
|
||||||
|
c == 1 and not e and not f and "Abendkontrolle IBKR (automatisch): **gescheitert**" in t,
|
||||||
|
f"code {c} eigene {e} fremde {f}"))
|
||||||
|
lauf("ohne --teil mit --ohne-wsl nur Alpaca", alpaca, teil=None, pruefe=lambda c, t, e, f: (
|
||||||
|
f and not e and "Abendkontrolle IBKR" not in t and "Abendkontrolle Trading Plattform" in t,
|
||||||
|
f"eigene {e} fremde {f}"))
|
||||||
|
|
||||||
|
# Beide Teile in derselben Notiz, Alpaca zuerst, je mit eigener Ergebnisdatei.
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
vault = Path(tmp)
|
||||||
|
(vault / "Inbox").mkdir()
|
||||||
|
(vault / "a.json").write_text(json.dumps(alpaca), encoding="utf-8")
|
||||||
|
(vault / "i.json").write_text(json.dumps(gut), encoding="utf-8")
|
||||||
|
gemeinsam = ["--vault", str(vault), "--tag", TAG, "--jetzt", "2026-09-23T22:45:00"]
|
||||||
|
ak.main(gemeinsam + ["--teil", "alpaca", "--ohne-wsl", str(vault / "a.json")])
|
||||||
|
ak.main(gemeinsam + ["--teil", "ibkr", "--ohne-wsl", str(vault / "i.json")])
|
||||||
|
text = (vault / "Inbox" / f"{TAG}.md").read_text(encoding="utf-8")
|
||||||
|
stelle_a = text.find("Abendkontrolle Trading Plattform")
|
||||||
|
stelle_i = text.find("Abendkontrolle IBKR")
|
||||||
|
ok = (0 <= stelle_a < stelle_i and text.count("## Agentenprotokoll") == 1
|
||||||
|
and (vault / ak.ABLAGE_TEIL / f"{TAG}.json").exists()
|
||||||
|
and (vault / ak.ABLAGE_TEIL / f"{TAG}-ibkr.json").exists())
|
||||||
|
pruefe_fall("beide Teile in einer Notiz", ok, f"alpaca {stelle_a} ibkr {stelle_i}")
|
||||||
|
|
||||||
|
# Relativ zu IBKR_ERSTER_TAG: Den Wert setzt der Koordinator vor Welle 1 (Planuebersicht IB2).
|
||||||
|
erster = ak.IBKR_ERSTER_TAG.isoformat()
|
||||||
|
pruefe_fall("IBKR_ERSTER_TAG ist ein Werktag nicht vor ERSTER_TAG",
|
||||||
|
ak.IBKR_ERSTER_TAG.weekday() < 5 and ak.IBKR_ERSTER_TAG >= ak.ERSTER_TAG, erster)
|
||||||
|
|
||||||
|
# Ohne --tag: am ersten Tag vor 22:30 ist fuer den Teil ibkr nichts faellig, auch kein Tag davor.
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
vault = Path(tmp)
|
||||||
|
(vault / "Inbox").mkdir()
|
||||||
|
(vault / "i.json").write_text(json.dumps(gut), encoding="utf-8")
|
||||||
|
ak.main(["--vault", str(vault), "--teil", "ibkr", "--ohne-wsl", str(vault / "i.json"), "--jetzt", f"{erster}T08:00:00"])
|
||||||
|
dateien = sorted(p.name for p in (vault / ak.ABLAGE_TEIL).glob("*-ibkr.json"))
|
||||||
|
pruefe_fall("ohne --tag nichts vor IBKR_ERSTER_TAG", dateien == [], str(dateien))
|
||||||
|
|
||||||
|
# faellige Tage je Teil, direkt
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
ablage = Path(tmp)
|
||||||
|
|
||||||
|
def ibkr_tage(jetzt):
|
||||||
|
return ak.faellige_tage(datetime.fromisoformat(jetzt), ablage, erster_tag=ak.IBKR_ERSTER_TAG,
|
||||||
|
endung=ak.IBKR_ENDUNG)
|
||||||
|
|
||||||
|
ergebnis = ibkr_tage(f"{erster}T08:00")
|
||||||
|
pruefe_fall("faellig: nichts vor IBKR_ERSTER_TAG", ergebnis == [], str(ergebnis))
|
||||||
|
ergebnis = ibkr_tage(f"{erster}T22:45")
|
||||||
|
pruefe_fall("faellig: der erste Tag nach 22:30", erster in ergebnis, str(ergebnis))
|
||||||
|
(ablage / "2026-09-29-ibkr.json").write_text("{}", encoding="utf-8")
|
||||||
|
ibkr = ibkr_tage("2026-09-30T08:00")
|
||||||
|
alpaca_tage = ak.faellige_tage(datetime.fromisoformat("2026-09-30T08:00"), ablage)
|
||||||
|
pruefe_fall("faellig: eigene Ergebnisdatei je Teil", "2026-09-29" not in ibkr and "2026-09-29" in alpaca_tage,
|
||||||
|
f"ibkr {ibkr} alpaca {alpaca_tage}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
windows_faelle()
|
windows_faelle()
|
||||||
|
ibkr_windows_faelle()
|
||||||
else:
|
else:
|
||||||
wsl_faelle()
|
wsl_faelle()
|
||||||
|
ibkr_wsl_faelle()
|
||||||
print(f"ERGEBNIS: {len(abweichungen)} Abweichung(en)")
|
print(f"ERGEBNIS: {len(abweichungen)} Abweichung(en)")
|
||||||
sys.exit(1 if abweichungen else 0)
|
sys.exit(1 if abweichungen else 0)
|
||||||
@@ -39,10 +39,45 @@ WSL_MUTANTEN = [
|
|||||||
("Fragen nicht voneinander getrennt", "abendkontrolle_wsl.py",
|
("Fragen nicht voneinander getrennt", "abendkontrolle_wsl.py",
|
||||||
" except Exception as ausnahme:\n self.befund(f\"{name} nicht geprueft",
|
" except Exception as ausnahme:\n self.befund(f\"{name} nicht geprueft",
|
||||||
" except ZeroDivisionError as ausnahme:\n self.befund(f\"{name} nicht geprueft"),
|
" except ZeroDivisionError as ausnahme:\n self.befund(f\"{name} nicht geprueft"),
|
||||||
|
# Seit Paket IB2: der Teil ibkr.
|
||||||
|
("IBKR Ordner egal", "abendkontrolle_wsl.py",
|
||||||
|
" if not ordner.is_dir():\n k.befund(f\"Betriebsordner", " if False:\n k.befund(f\"Betriebsordner"),
|
||||||
|
("IBKR Marke egal", "abendkontrolle_wsl.py", ' if marke != "ibkr":', " if False:"),
|
||||||
|
("IBKR gesperrt egal", "abendkontrolle_wsl.py", 'if zustand == "gesperrt":', 'if zustand == "nie":'),
|
||||||
|
("IBKR Register nicht gelesen", "abendkontrolle_wsl.py",
|
||||||
|
' k.schritt("IBKR-Killswitch", pruefe_register_ibkr, ordner, k)', " pass"),
|
||||||
|
("IBKR Schlussposition egal", "abendkontrolle_wsl.py",
|
||||||
|
' elif ende.get("position") != 0:', " elif False:"),
|
||||||
|
("IBKR Lauf ohne ende still", "abendkontrolle_wsl.py",
|
||||||
|
' if ende is None:\n # Im Alpaca-Teil meldet das', ' if False:\n # Im Alpaca-Teil meldet das'),
|
||||||
|
("IBKR Zeitgeber zaehlt nicht", "abendkontrolle_wsl.py",
|
||||||
|
'if not dateien and zeitgeber is not None and zeitgeber != "active":', "if not dateien:"),
|
||||||
|
("IBKR nicht gestartet als Befund", "abendkontrolle_wsl.py", 'k.hinweis(f"{NICHT_GESTARTET} (', 'k.befund(f"{NICHT_GESTARTET} ('),
|
||||||
|
("IBKR Positionen egal", "abendkontrolle_wsl.py", " if positionen != 0:", " if positionen > 5:"),
|
||||||
|
("IBKR Orders egal", "abendkontrolle_wsl.py", " if orders != 0:", " if False:"),
|
||||||
|
("IBKR fehlende Kontozeile egal", "abendkontrolle_wsl.py", " if positionen is None or orders is None:", " if False:"),
|
||||||
|
("IBKR Kontonummer sichtbar", "abendkontrolle_wsl.py",
|
||||||
|
'return maskiere(KONTONUMMER.sub("[konto]", text))', "return maskiere(text)"),
|
||||||
|
("IBKR Handelsfenster egal", "abendkontrolle_wsl.py",
|
||||||
|
" return not IBKR_SPERRE_VON <= (ny.hour, ny.minute) < IBKR_SPERRE_BIS", " return True"),
|
||||||
|
("IBKR Journal mit Alpaca-Dienst", "abendkontrolle_wsl.py", "pruefe_journal(text, k, IBKR_DIENST)", "pruefe_journal(text, k)"),
|
||||||
|
("IBKR Muster aus", "abendkontrolle_wsl.py", " if muster in text:", " if False:"),
|
||||||
|
("IBKR Titel ohne Praefix", "abendkontrolle_wsl.py", 'IBKR_TITEL = "IBKR "', 'IBKR_TITEL = ""'),
|
||||||
|
("IBKR Meldeweiche aus", "abendkontrolle_wsl.py", "melde(IBKR_TITEL + titel if ibkr else titel,", "melde(titel,"),
|
||||||
]
|
]
|
||||||
WIN_MUTANTEN = [
|
WIN_MUTANTEN = [
|
||||||
("keine Ergebnisdatei", "abendkontrolle.py", '(ablage / f"{tag}.json").write_text(', '(ablage / f"{tag}.nix").write_text('),
|
("keine Ergebnisdatei", "abendkontrolle.py", '(ablage / f"{tag}{endung}.json").write_text(',
|
||||||
("Wochenende mit", "abendkontrolle.py", "if tag < ERSTER_TAG or tag.weekday() >= 5:", "if tag < ERSTER_TAG:"),
|
'(ablage / f"{tag}{endung}.nix").write_text('),
|
||||||
|
("Wochenende mit", "abendkontrolle.py", "if tag < erster_tag or tag.weekday() >= 5:", "if tag < erster_tag:"),
|
||||||
|
# Seit Paket IB2: der Teil ibkr.
|
||||||
|
("IBKR in die Alpaca-Datei", "abendkontrolle.py", 'IBKR_ENDUNG = "-ibkr"', 'IBKR_ENDUNG = ""'),
|
||||||
|
("IBKR mit Alpaca-Kopf", "abendkontrolle.py", ' "ibkr": "Abendkontrolle IBKR (automatisch)",',
|
||||||
|
' "ibkr": "Abendkontrolle Trading Plattform (automatisch)",'),
|
||||||
|
("IBKR nicht gestartet unsichtbar", "abendkontrolle.py", ' if fakten.get("nicht_gestartet") and not befunde:', " if False:"),
|
||||||
|
("IBKR ohne eigenen Starttag", "abendkontrolle.py", "erster_tag=IBKR_ERSTER_TAG, endung=IBKR_ENDUNG", "endung=IBKR_ENDUNG"),
|
||||||
|
("mit --ohne-wsl beide Teile", "abendkontrolle.py", '(["alpaca"] if argumente.ohne_wsl else list(TEILE))', "list(TEILE)"),
|
||||||
|
("IBKR-Zeile ohne Konto", "abendkontrolle.py", ' konto = fakten.get("konto") or {}', " konto = {}"),
|
||||||
|
("Teil ungeprueft", "abendkontrolle.py", 'if ergebnis.get("teil", "alpaca") != teil:', "if False:"),
|
||||||
("heute vor 22:30", "abendkontrolle.py", "if tag == jetzt.date() and jetzt.time() < FRUEHESTENS:", "if False:"),
|
("heute vor 22:30", "abendkontrolle.py", "if tag == jetzt.date() and jetzt.time() < FRUEHESTENS:", "if False:"),
|
||||||
("Befund ohne Markierung", "abendkontrolle.py", "**{len(befunde)} Befund(e)** #needs-review", "{len(befunde)} Befund(e)"),
|
("Befund ohne Markierung", "abendkontrolle.py", "**{len(befunde)} Befund(e)** #needs-review", "{len(befunde)} Befund(e)"),
|
||||||
("CRLF vergessen", "abendkontrolle.py", 'cr = "\\r" if any(z.endswith("\\r") for z in zeilen) else ""', 'cr = ""'),
|
("CRLF vergessen", "abendkontrolle.py", 'cr = "\\r" if any(z.endswith("\\r") for z in zeilen) else ""', 'cr = ""'),
|
||||||
|
|||||||
@@ -55,11 +55,38 @@ Zeile in der Tagesnotiz (benannte Luecke; der Totmann in Home Assistant, Paket Z
|
|||||||
Nicht gesehen: die Guete der Kurse (steht im Bericht), Teilausfuehrungen einzeln, alles vor dem
|
Nicht gesehen: die Guete der Kurse (steht im Bericht), Teilausfuehrungen einzeln, alles vor dem
|
||||||
Handelstag. Betraege gibt das Werkzeug nie aus; fremde Zeilen laufen durch maskiere().
|
Handelstag. Betraege gibt das Werkzeug nie aus; fremde Zeilen laufen durch maskiere().
|
||||||
|
|
||||||
|
Seit Paket IB2 prueft --teil ibkr statt des Alpaca-Runners die IBKR-Instanz in ihrem Betriebsordner
|
||||||
|
(--ibkr-ordner, Vorgabe IBKR_ORDNER), mit eigener Ausgabe und eigener Meldung (Titel "IBKR ..."):
|
||||||
|
I1. Handelstag? wie Frage 1, derselbe Kalender von Alpaca.
|
||||||
|
I2. Lief die Instanz? Protokolle <tag>T*-paper.jsonl unter artifacts/paper des Betriebsordners.
|
||||||
|
Keines und trading-paper-ibkr.timer nicht active -> nur der Hinweis
|
||||||
|
"IBKR-Instanz heute nicht gestartet", kein Befund, der Rest entfaellt
|
||||||
|
(No-Go-Tag). Keines bei aktivem oder unlesbarem Zeitgeber -> Befund.
|
||||||
|
Der Zeitgeber wird zum Zeitpunkt der Kontrolle gelesen, nicht fuer
|
||||||
|
den geprueften Tag -- beim Nachholen eine benannte Luecke.
|
||||||
|
I3. Ist der Ordner die Instanz? data/paper/broker.txt sagt ibkr, sonst Befund.
|
||||||
|
I4. Killswitch der Instanz? killswitch_zustand in data/registry.db des Betriebsordners (mode=ro);
|
||||||
|
gesperrt -> Befund mit Stufe und Ausloeser des juengsten Ereignisses.
|
||||||
|
I5. Ende je Lauf, Handel? wie Fragen 3 und 6 (pruefe_laeufe); dazu die Schlussposition jedes
|
||||||
|
Laufs aus seiner Zeile ende (pruefe_schlusspositionen), unabhaengig
|
||||||
|
vom Journal: Position != 0 -> Befund.
|
||||||
|
I6. Journal? wie Frage 3 fuer trading-paper-ibkr.service; jede Zeile mit einem
|
||||||
|
Muster aus IBKR_MUSTER -> Befund.
|
||||||
|
I7. Konto am Gateway glatt? scripts/ibkr_pruefen.py lesen im Betriebsordner mit IBKR_PY (Client-ID
|
||||||
|
12, lesend), nur werktags ausserhalb 09:00 bis 16:15 New Yorker Zeit
|
||||||
|
und am Wochenende, sonst ein Hinweis. Weiter gehen nur die Zahlen aus
|
||||||
|
"Positionen: n" und "Offene Orders: n" und die BEFUND-Zeilen, maskiert,
|
||||||
|
Kontonummern als [konto]. n != 0, eine fehlende Zeile oder Exit != 0 ->
|
||||||
|
Befund.
|
||||||
|
Nicht gesehen im Teil ibkr: ein Tagesbericht (die Instanz hat keine Tagesauswertung),
|
||||||
|
handelstag_auswertung.py und der Dienst ibkr-gateway.service.
|
||||||
|
|
||||||
Ein Fangnetz, kein Nachweis ueber den Handelstag.
|
Ein Fangnetz, kein Nachweis ueber den Handelstag.
|
||||||
Aufruf (WSL, Venv): python abendkontrolle_wsl.py --tag JJJJ-MM-TT [--ohne-meldung]
|
Aufruf (WSL, Venv): python abendkontrolle_wsl.py --tag JJJJ-MM-TT [--ohne-meldung] [--teil ibkr]
|
||||||
Nur eine Meldung schicken: python abendkontrolle_wsl.py --meldung "<titel>" "<text>"
|
Nur eine Meldung schicken: python abendkontrolle_wsl.py --meldung "<titel>" "<text>"
|
||||||
Fuer Angriffsfaelle ohne Netz und ohne systemd: --projekt, --kalender, --journal, --dienste,
|
Fuer Angriffsfaelle ohne Netz und ohne systemd: --projekt, --kalender, --journal, --dienste,
|
||||||
--ohne-broker, --ohne-kerzen.
|
--ohne-broker, --ohne-kerzen; im Teil ibkr dazu --ibkr-ordner, --ibkr-konto (JSON mit exit und
|
||||||
|
text statt des Aufrufs von ibkr_pruefen.py) und --jetzt (ISO-Zeitpunkt mit Versatz).
|
||||||
Ausgabe: genau eine JSON-Zeile auf stdout. Exit 0 gelaufen (Befunde stehen in der Ausgabe),
|
Ausgabe: genau eine JSON-Zeile auf stdout. Exit 0 gelaufen (Befunde stehen in der Ausgabe),
|
||||||
2 abgebrochen (die bis dahin gesammelten Befunde stehen trotzdem in der Ausgabe).
|
2 abgebrochen (die bis dahin gesammelten Befunde stehen trotzdem in der Ausgabe).
|
||||||
"""
|
"""
|
||||||
@@ -73,8 +100,10 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
from datetime import datetime
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
PROJEKT = "/mnt/d/Claude/Code Projekte/active - Trading Plattform"
|
PROJEKT = "/mnt/d/Claude/Code Projekte/active - Trading Plattform"
|
||||||
GEHEIMNISSE = "/home/claude/.config/trading-plattform/secrets.toml"
|
GEHEIMNISSE = "/home/claude/.config/trading-plattform/secrets.toml"
|
||||||
@@ -87,6 +116,30 @@ DIENSTE_NOCHMAL_S = 30
|
|||||||
KILLSWITCH_ZUSTAENDE = ("aktiv", "angehalten", "gesperrt")
|
KILLSWITCH_ZUSTAENDE = ("aktiv", "angehalten", "gesperrt")
|
||||||
AUSWERTUNG_TIMEOUT_S = 900
|
AUSWERTUNG_TIMEOUT_S = 900
|
||||||
|
|
||||||
|
# Seit Paket IB2: die IBKR-Instanz im eigenen Betriebsordner (Unit trading-paper-ibkr.service).
|
||||||
|
IBKR_ORDNER = "/mnt/d/Claude/.arbeitsbaeume/trading-ibkr-betrieb"
|
||||||
|
IBKR_PY = "/home/claude/venvs/trading-ibkr/bin/python"
|
||||||
|
IBKR_DIENST = "trading-paper-ibkr.service"
|
||||||
|
IBKR_ZEITGEBER = "trading-paper-ibkr.timer"
|
||||||
|
IBKR_MARKE = Path("data") / "paper" / "broker.txt"
|
||||||
|
IBKR_TITEL = "IBKR "
|
||||||
|
IBKR_LESEN_TIMEOUT_S = 180
|
||||||
|
# Journalzeilen, die ohne Ausnahme ein Befund sind: ein Traceback, und der Satz, mit dem
|
||||||
|
# scripts/paper.py abbricht, wenn Instanzmarke und Konfiguration nicht zusammenpassen.
|
||||||
|
IBKR_MUSTER = ("Traceback (most recent call last)", "Nichts wird gebaut")
|
||||||
|
# Das Konto wird am Gateway nur ausserhalb dieses Fensters gelesen (New Yorker Zeit, werktags):
|
||||||
|
# Waehrend der Handelszeit gehoerten Positionen und Orders schon zum naechsten Handelstag.
|
||||||
|
NEW_YORK = ZoneInfo("America/New_York")
|
||||||
|
IBKR_SPERRE_VON = (9, 0)
|
||||||
|
IBKR_SPERRE_BIS = (16, 15)
|
||||||
|
IBKR_POSITIONEN = re.compile(r"^Positionen: (\d+)[ \t\r]*$", re.MULTILINE)
|
||||||
|
IBKR_ORDERS = re.compile(r"^Offene Orders: (\d+)[ \t\r]*$", re.MULTILINE)
|
||||||
|
# IBKR-Kontonummern: Buchstaben vorn, dann Ziffern. Das Paper-Konto hat mehr als "DU" vorn;
|
||||||
|
# die erste Fassung (nur DU/U) traf die echte Nummer nicht (Pruefung IB2, 2026-09-25). Dieselbe
|
||||||
|
# Form wie im Projekt (trading/broker/ibkr.py). Nie in Notiz oder Meldung.
|
||||||
|
KONTONUMMER = re.compile(r"\b[A-Z]{1,4}[0-9]{5,}\b")
|
||||||
|
NICHT_GESTARTET = "IBKR-Instanz heute nicht gestartet"
|
||||||
|
|
||||||
ENDE = re.compile(
|
ENDE = re.compile(
|
||||||
r"Ende: (?P<grund>[^,]+), Position (?P<position>[^,]+), Orders (?P<orders>[^,]+), "
|
r"Ende: (?P<grund>[^,]+), Position (?P<position>[^,]+), Orders (?P<orders>[^,]+), "
|
||||||
r"Killswitch (?P<killswitch>[^,]+), Exit (?P<exit>-?\d+)"
|
r"Killswitch (?P<killswitch>[^,]+), Exit (?P<exit>-?\d+)"
|
||||||
@@ -238,12 +291,12 @@ def pruefe_laeufe(laeufe: list[dict], k: Kontrolle) -> None:
|
|||||||
|
|
||||||
# --- 3 Journal -------------------------------------------------------------------------------------
|
# --- 3 Journal -------------------------------------------------------------------------------------
|
||||||
|
|
||||||
def lies_journal(tag: str, journal_datei: str | None) -> str:
|
def lies_journal(tag: str, journal_datei: str | None, dienst: str = "trading-paper.service") -> str:
|
||||||
if journal_datei is not None:
|
if journal_datei is not None:
|
||||||
return Path(journal_datei).read_text(encoding="utf-8", errors="replace")
|
return Path(journal_datei).read_text(encoding="utf-8", errors="replace")
|
||||||
morgen = (date.fromisoformat(tag) + timedelta(days=1)).isoformat()
|
morgen = (date.fromisoformat(tag) + timedelta(days=1)).isoformat()
|
||||||
lauf = subprocess.run(
|
lauf = subprocess.run(
|
||||||
["journalctl", "-u", "trading-paper.service", "--since", f"{tag} 00:00", "--until", f"{morgen} 06:00",
|
["journalctl", "-u", dienst, "--since", f"{tag} 00:00", "--until", f"{morgen} 06:00",
|
||||||
"-o", "cat", "--no-pager"],
|
"-o", "cat", "--no-pager"],
|
||||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60,
|
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60,
|
||||||
)
|
)
|
||||||
@@ -252,7 +305,7 @@ def lies_journal(tag: str, journal_datei: str | None) -> str:
|
|||||||
return lauf.stdout
|
return lauf.stdout
|
||||||
|
|
||||||
|
|
||||||
def pruefe_journal(text: str, k: Kontrolle) -> dict | None:
|
def pruefe_journal(text: str, k: Kontrolle, dienst: str = "trading-paper.service") -> dict | None:
|
||||||
alle = [{schluessel: wert.strip() for schluessel, wert in m.groupdict().items()} for m in ENDE.finditer(text)]
|
alle = [{schluessel: wert.strip() for schluessel, wert in m.groupdict().items()} for m in ENDE.finditer(text)]
|
||||||
# Ein Lauf ausserhalb der Handelszeit (Neustart nach Boersenschluss) endet mit wartezeit und Exit 3.
|
# Ein Lauf ausserhalb der Handelszeit (Neustart nach Boersenschluss) endet mit wartezeit und Exit 3.
|
||||||
enden = [satz for satz in alle if satz["grund"] != "wartezeit"]
|
enden = [satz for satz in alle if satz["grund"] != "wartezeit"]
|
||||||
@@ -260,9 +313,9 @@ def pruefe_journal(text: str, k: Kontrolle) -> dict | None:
|
|||||||
if len(alle) > len(enden):
|
if len(alle) > len(enden):
|
||||||
k.hinweis(f"{len(alle) - len(enden)} Lauf/Laeufe ausserhalb der Handelszeit im Journal (Ende wartezeit)")
|
k.hinweis(f"{len(alle) - len(enden)} Lauf/Laeufe ausserhalb der Handelszeit im Journal (Ende wartezeit)")
|
||||||
if "Failed with result" in text:
|
if "Failed with result" in text:
|
||||||
k.befund("Journal: trading-paper.service endete mit 'Failed with result'")
|
k.befund(f"Journal: {dienst} endete mit 'Failed with result'")
|
||||||
if not enden:
|
if not enden:
|
||||||
k.befund("Journal: keine Zeile 'Ende: …, Exit n' eines Handelslaufs von trading-paper.service an diesem Tag")
|
k.befund(f"Journal: keine Zeile 'Ende: …, Exit n' eines Handelslaufs von {dienst} an diesem Tag")
|
||||||
return None
|
return None
|
||||||
# Jeder Lauf des Tages zaehlt: Ein Runner, der mittags mit einer Stoerung endete und neu gestartet
|
# Jeder Lauf des Tages zaehlt: Ein Runner, der mittags mit einer Stoerung endete und neu gestartet
|
||||||
# wurde, ist auch dann ein Befund, wenn der zweite sauber schliesst.
|
# wurde, ist auch dann ein Befund, wenn der zweite sauber schliesst.
|
||||||
@@ -354,6 +407,178 @@ def pruefe_killswitch(projekt: Path, k: Kontrolle) -> None:
|
|||||||
k.befund(f"Killswitch in unbekanntem Zustand {zeile[0]!r}")
|
k.befund(f"Killswitch in unbekanntem Zustand {zeile[0]!r}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- IBKR-Instanz (seit Paket IB2) -----------------------------------------------------------------
|
||||||
|
|
||||||
|
def pruefe_marke_ibkr(ordner: Path, k: Kontrolle) -> None:
|
||||||
|
datei = ordner / IBKR_MARKE
|
||||||
|
marke = datei.read_text(encoding="utf-8").strip() if datei.is_file() else None
|
||||||
|
k.fakten["marke"] = marke
|
||||||
|
if marke != "ibkr":
|
||||||
|
k.befund(
|
||||||
|
f"Instanzmarke {IBKR_MARKE.as_posix()} sagt {marke!r} statt 'ibkr': "
|
||||||
|
"Das ist nicht der Betriebsordner der IBKR-Instanz"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_register_ibkr(ordner: Path, k: Kontrolle) -> None:
|
||||||
|
"""Zustand und juengstes Ereignis aus dem Register der Instanz, nur lesend (mode=ro)."""
|
||||||
|
register = ordner / "data" / "registry.db"
|
||||||
|
if not register.is_file():
|
||||||
|
k.befund("IBKR-Register data/registry.db fehlt im Betriebsordner")
|
||||||
|
return
|
||||||
|
with sqlite3.connect(f"file:{register}?mode=ro", uri=True, timeout=10) as verbindung:
|
||||||
|
stand = verbindung.execute(
|
||||||
|
"SELECT zustand, seit, ausloeser FROM killswitch_zustand WHERE id = 1"
|
||||||
|
).fetchone()
|
||||||
|
letztes = verbindung.execute(
|
||||||
|
"SELECT stufe, ausloeser FROM killswitch_ereignisse ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if stand is None:
|
||||||
|
k.befund("IBKR-Register: kein Killswitch-Zustand")
|
||||||
|
return
|
||||||
|
zustand = stand[0]
|
||||||
|
k.fakten["killswitch"] = {"zustand": zustand, "seit": stand[1], "ausloeser": stand[2]}
|
||||||
|
if zustand == "gesperrt":
|
||||||
|
grund = "ohne Ereignis im Register" if letztes is None else f"letztes Ereignis {letztes[0]} von {letztes[1]}"
|
||||||
|
k.befund(f"IBKR-Killswitch steht auf gesperrt ({grund}): Ohne Freigeben an der Konsole handelt die Instanz nicht")
|
||||||
|
elif zustand not in KILLSWITCH_ZUSTAENDE:
|
||||||
|
k.befund(f"IBKR-Killswitch in unbekanntem Zustand {zustand!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_schlusspositionen(laeufe: list[dict], k: Kontrolle) -> None:
|
||||||
|
"""Die Schlussposition jedes Laufs aus seiner Zeile ende, unabhaengig vom Journal.
|
||||||
|
|
||||||
|
pruefe_laeufe bewertet die Position nicht, und pruefe_journal nur die des letzten Laufs;
|
||||||
|
der Alpaca-Teil faengt sie ueber handelstag_auswertung.py, das der Teil ibkr nicht faehrt.
|
||||||
|
Ebenso einen Lauf ohne Zeile ende: pruefe_laeufe ueberspringt ihn, weil im Alpaca-Teil die
|
||||||
|
Auswertung ihn meldet (Nachkritik IB2).
|
||||||
|
"""
|
||||||
|
for lauf in laeufe:
|
||||||
|
ende = lauf["ende"]
|
||||||
|
if ende is None:
|
||||||
|
# Im Alpaca-Teil meldet das handelstag_auswertung.py; hier sonst niemand.
|
||||||
|
k.befund(f"{lauf['datei']}: Lauf ohne Zeile ende im Protokoll")
|
||||||
|
elif ende.get("position") != 0:
|
||||||
|
k.befund(f"{lauf['datei']}: Lauf endete laut Protokoll mit Position {ende.get('position')}")
|
||||||
|
|
||||||
|
|
||||||
|
def lies_zeitgeber_ibkr(dienste_datei: str | None) -> str:
|
||||||
|
"""Der Zustand von trading-paper-ibkr.timer (active, inactive, ...); aus einer Datei in den Angriffsfaellen.
|
||||||
|
|
||||||
|
Gelesen wird der Zustand jetzt, nicht der am geprueften Tag: Beim Nachholen kann er sich
|
||||||
|
inzwischen geaendert haben (benannte Luecke).
|
||||||
|
"""
|
||||||
|
if dienste_datei is not None:
|
||||||
|
zustaende = json.loads(Path(dienste_datei).read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(zustaende, dict):
|
||||||
|
raise ValueError("Dienste-Datei ist kein Objekt")
|
||||||
|
return str(zustaende.get(IBKR_ZEITGEBER, "fehlt"))
|
||||||
|
lauf = subprocess.run(["systemctl", "is-active", IBKR_ZEITGEBER], capture_output=True, text=True, timeout=30)
|
||||||
|
return lauf.stdout.strip() or f"exit {lauf.returncode}"
|
||||||
|
|
||||||
|
|
||||||
|
def lesen_erlaubt(jetzt: datetime) -> bool:
|
||||||
|
"""Ob der Stand am Gateway jetzt fuer den Tagesschluss spricht: Wochenende, oder werktags
|
||||||
|
ausserhalb IBKR_SPERRE_VON bis IBKR_SPERRE_BIS New Yorker Zeit."""
|
||||||
|
ny = jetzt.astimezone(NEW_YORK)
|
||||||
|
if ny.weekday() >= 5:
|
||||||
|
return True
|
||||||
|
return not IBKR_SPERRE_VON <= (ny.hour, ny.minute) < IBKR_SPERRE_BIS
|
||||||
|
|
||||||
|
|
||||||
|
def lies_konto_ibkr(ordner: Path, konto_datei: str | None) -> tuple[int, str]:
|
||||||
|
"""Exit und Ausgabe (stdout und stderr) von scripts/ibkr_pruefen.py lesen, oder aus einer Datei."""
|
||||||
|
if konto_datei is not None:
|
||||||
|
daten = json.loads(Path(konto_datei).read_text(encoding="utf-8"))
|
||||||
|
return int(daten["exit"]), str(daten["text"])
|
||||||
|
lauf = subprocess.run(
|
||||||
|
[IBKR_PY, "scripts/ibkr_pruefen.py", "lesen"], cwd=ordner, capture_output=True, text=True,
|
||||||
|
encoding="utf-8", errors="replace", timeout=IBKR_LESEN_TIMEOUT_S,
|
||||||
|
env={**os.environ, "PYTHONIOENCODING": "utf-8"},
|
||||||
|
)
|
||||||
|
return lauf.returncode, lauf.stdout + "\n" + lauf.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def ohne_kontonummer(text: str) -> str:
|
||||||
|
# Erst die Kontonummer, dann die Maske: Sonst machte die Maske aus "Konto DU..." ein
|
||||||
|
# "Konto DU[x]", und die Kontonummer-Regel faende nichts mehr.
|
||||||
|
return maskiere(KONTONUMMER.sub("[konto]", text))
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_konto_ibkr(code: int, text: str, k: Kontrolle) -> None:
|
||||||
|
"""Positionen und offene Orders am Gateway; aus der Ausgabe gehen nur Zahlen und Befunde weiter."""
|
||||||
|
treffer_positionen = IBKR_POSITIONEN.search(text)
|
||||||
|
treffer_orders = IBKR_ORDERS.search(text)
|
||||||
|
positionen = int(treffer_positionen.group(1)) if treffer_positionen else None
|
||||||
|
orders = int(treffer_orders.group(1)) if treffer_orders else None
|
||||||
|
k.fakten["konto"] = {"positionen": positionen, "orders": orders, "exit": code}
|
||||||
|
for zeile in text.splitlines():
|
||||||
|
if zeile.strip().startswith(("BEFUND", "Voraussetzung fehlt")):
|
||||||
|
k.befund(f"ibkr_pruefen.py lesen: {ohne_kontonummer(zeile.strip())[:200]}")
|
||||||
|
if code != 0:
|
||||||
|
k.befund(f"ibkr_pruefen.py lesen endete mit Exit {code}")
|
||||||
|
if positionen is None or orders is None:
|
||||||
|
k.befund("Konto am Gateway nicht gelesen: keine Zeile 'Positionen: n' oder 'Offene Orders: n'")
|
||||||
|
return
|
||||||
|
if positionen != 0:
|
||||||
|
k.befund(f"Beim IBKR-Gateway stehen {positionen} Position(en)")
|
||||||
|
if orders != 0:
|
||||||
|
k.befund(f"Beim IBKR-Gateway stehen {orders} offene Order(s)")
|
||||||
|
|
||||||
|
|
||||||
|
def kontrolliere_ibkr(argumente: argparse.Namespace, k: Kontrolle) -> None:
|
||||||
|
ordner = Path(argumente.ibkr_ordner)
|
||||||
|
tag = argumente.tag
|
||||||
|
date.fromisoformat(tag) # ein falsches Datum ist ein Bedienfehler und bricht ab
|
||||||
|
k.fakten["ordner"] = str(ordner)
|
||||||
|
if not ordner.is_dir():
|
||||||
|
k.befund(f"Betriebsordner der IBKR-Instanz fehlt: {ordner}")
|
||||||
|
return
|
||||||
|
liste = k.schritt("Kalender, und damit Lauf, Ende und Konto,", lies_kalender, tag, argumente.kalender,
|
||||||
|
Path(argumente.projekt))
|
||||||
|
handelstag = None if liste is None else ist_handelstag(tag, liste)
|
||||||
|
k.fakten["handelstag"] = handelstag
|
||||||
|
if handelstag is None:
|
||||||
|
if liste is not None:
|
||||||
|
k.befund("Kalender in unerwarteter Form; Lauf, Ende und Konto der IBKR-Instanz ungeprueft")
|
||||||
|
return
|
||||||
|
dateien = protokolle(ordner, tag)
|
||||||
|
if not handelstag:
|
||||||
|
if dateien:
|
||||||
|
k.hinweis(f"kein Handelstag, aber {len(dateien)} Protokoll(e) der IBKR-Instanz")
|
||||||
|
return
|
||||||
|
zeitgeber = k.schritt("Zeitgeber der IBKR-Instanz", lies_zeitgeber_ibkr, argumente.dienste)
|
||||||
|
k.fakten["zeitgeber"] = zeitgeber
|
||||||
|
# No-Go-Tag: Pascal hat den Zeitgeber abgeschaltet, und die Instanz lief nicht. Leise.
|
||||||
|
if not dateien and zeitgeber is not None and zeitgeber != "active":
|
||||||
|
k.fakten["nicht_gestartet"] = True
|
||||||
|
k.hinweis(f"{NICHT_GESTARTET} ({IBKR_ZEITGEBER}: {zeitgeber})")
|
||||||
|
return
|
||||||
|
k.schritt("Instanzmarke", pruefe_marke_ibkr, ordner, k)
|
||||||
|
k.schritt("IBKR-Killswitch", pruefe_register_ibkr, ordner, k)
|
||||||
|
if not dateien:
|
||||||
|
k.befund(f"kein Protokoll der IBKR-Instanz an einem Handelstag, obwohl {IBKR_ZEITGEBER} "
|
||||||
|
f"{zeitgeber} ist -- die Instanz lief nicht")
|
||||||
|
laeufe = [lauf for lauf in (k.schritt(f"Protokoll {pfad.name}", lies_protokoll, pfad, k) for pfad in dateien) if lauf]
|
||||||
|
k.fakten["laeufe"] = laeufe
|
||||||
|
pruefe_laeufe(laeufe, k)
|
||||||
|
pruefe_schlusspositionen(laeufe, k)
|
||||||
|
text = k.schritt("Journal", lies_journal, tag, argumente.journal, IBKR_DIENST)
|
||||||
|
if text is not None:
|
||||||
|
k.fakten["ende"] = pruefe_journal(text, k, IBKR_DIENST)
|
||||||
|
for muster in IBKR_MUSTER:
|
||||||
|
if muster in text:
|
||||||
|
k.befund(f"Journal: {IBKR_DIENST} zeigt '{muster}'")
|
||||||
|
jetzt = datetime.fromisoformat(argumente.jetzt) if argumente.jetzt else datetime.now(NEW_YORK)
|
||||||
|
if not lesen_erlaubt(jetzt):
|
||||||
|
k.hinweis(f"Konto am IBKR-Gateway nicht gelesen: {jetzt.astimezone(NEW_YORK):%H:%M} New Yorker Zeit "
|
||||||
|
"liegt im Handelsfenster")
|
||||||
|
return
|
||||||
|
ergebnis = k.schritt("Konto am IBKR-Gateway", lies_konto_ibkr, ordner, argumente.ibkr_konto)
|
||||||
|
if ergebnis is not None:
|
||||||
|
pruefe_konto_ibkr(ergebnis[0], ergebnis[1], k)
|
||||||
|
|
||||||
|
|
||||||
# --- Meldung ---------------------------------------------------------------------------------------
|
# --- Meldung ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
def melde(titel: str, text: str, projekt: Path) -> str:
|
def melde(titel: str, text: str, projekt: Path) -> str:
|
||||||
@@ -439,6 +664,12 @@ def parse_args(argv=None) -> argparse.Namespace:
|
|||||||
zerleger.add_argument("--ohne-broker", action="store_true")
|
zerleger.add_argument("--ohne-broker", action="store_true")
|
||||||
zerleger.add_argument("--ohne-kerzen", action="store_true")
|
zerleger.add_argument("--ohne-kerzen", action="store_true")
|
||||||
zerleger.add_argument("--ohne-meldung", action="store_true")
|
zerleger.add_argument("--ohne-meldung", action="store_true")
|
||||||
|
# Seit Paket IB2: der Teil ibkr prueft die IBKR-Instanz statt des Alpaca-Runners.
|
||||||
|
zerleger.add_argument("--teil", choices=("alpaca", "ibkr"), default="alpaca")
|
||||||
|
zerleger.add_argument("--ibkr-ordner", default=IBKR_ORDNER)
|
||||||
|
# Nur fuer die Angriffsfaelle: Exit und Ausgabe von ibkr_pruefen.py als JSON-Datei, und die Uhr.
|
||||||
|
zerleger.add_argument("--ibkr-konto")
|
||||||
|
zerleger.add_argument("--jetzt", help="ISO-Zeitpunkt mit Versatz statt der Uhr")
|
||||||
argumente = zerleger.parse_args(argv)
|
argumente = zerleger.parse_args(argv)
|
||||||
if bool(argumente.tag) == bool(argumente.meldung):
|
if bool(argumente.tag) == bool(argumente.meldung):
|
||||||
zerleger.error("genau eines von --tag und --meldung")
|
zerleger.error("genau eines von --tag und --meldung")
|
||||||
@@ -454,18 +685,22 @@ def main(argv=None) -> int:
|
|||||||
|
|
||||||
k = Kontrolle()
|
k = Kontrolle()
|
||||||
code = 0
|
code = 0
|
||||||
|
ibkr = argumente.teil == "ibkr"
|
||||||
try:
|
try:
|
||||||
kontrolliere(argumente, k)
|
if ibkr:
|
||||||
|
kontrolliere_ibkr(argumente, k)
|
||||||
|
else:
|
||||||
|
kontrolliere(argumente, k)
|
||||||
except Exception as ausnahme:
|
except Exception as ausnahme:
|
||||||
k.befund(f"Abendkontrolle abgebrochen: {type(ausnahme).__name__}: {ausnahme}")
|
k.befund(f"Abendkontrolle abgebrochen: {type(ausnahme).__name__}: {ausnahme}")
|
||||||
code = 2
|
code = 2
|
||||||
if k.befunde and not argumente.ohne_meldung:
|
if k.befunde and not argumente.ohne_meldung:
|
||||||
text = "\n".join(f"- {befund}" for befund in k.befunde[:12])
|
text = "\n".join(f"- {befund}" for befund in k.befunde[:12])
|
||||||
k.fakten["meldung"] = melde(
|
titel = f"Abendkontrolle {argumente.tag}: {len(k.befunde)} Befund(e)"
|
||||||
f"Abendkontrolle {argumente.tag}: {len(k.befunde)} Befund(e)", text, Path(argumente.projekt)
|
k.fakten["meldung"] = melde(IBKR_TITEL + titel if ibkr else titel, text, Path(argumente.projekt))
|
||||||
)
|
|
||||||
print(json.dumps(
|
print(json.dumps(
|
||||||
{"tag": argumente.tag, "befunde": k.befunde, "hinweise": k.hinweise, "fakten": k.fakten, "exit": code},
|
{"tag": argumente.tag, "teil": argumente.teil, "befunde": k.befunde, "hinweise": k.hinweise,
|
||||||
|
"fakten": k.fakten, "exit": code},
|
||||||
ensure_ascii=False, default=str,
|
ensure_ascii=False, default=str,
|
||||||
))
|
))
|
||||||
return code
|
return code
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ def main() -> int:
|
|||||||
seite.get_by_label("Benutzer").fill(zugang["benutzer"])
|
seite.get_by_label("Benutzer").fill(zugang["benutzer"])
|
||||||
seite.get_by_label("Passwort").fill(zugang["passwort"])
|
seite.get_by_label("Passwort").fill(zugang["passwort"])
|
||||||
seite.get_by_label("Code").fill(totp(zugang["geheimnis"], time.time()))
|
seite.get_by_label("Code").fill(totp(zugang["geheimnis"], time.time()))
|
||||||
seite.get_by_role("button", name="Anmelden").click()
|
seite.get_by_role("button", name="Anmelden", exact=True).click()
|
||||||
try:
|
try:
|
||||||
seite.get_by_role("button", name="Abmelden").wait_for(timeout=10000)
|
seite.get_by_role("button", name="Abmelden").wait_for(timeout=10000)
|
||||||
except PlaywrightTimeout:
|
except PlaywrightTimeout:
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ def main() -> int:
|
|||||||
seite.get_by_label("Benutzer").fill(zugang["benutzer"])
|
seite.get_by_label("Benutzer").fill(zugang["benutzer"])
|
||||||
seite.get_by_label("Passwort").fill("ganz-falsches-passwort")
|
seite.get_by_label("Passwort").fill("ganz-falsches-passwort")
|
||||||
seite.get_by_label("Code").fill("000000")
|
seite.get_by_label("Code").fill("000000")
|
||||||
seite.get_by_role("button", name="Anmelden").click()
|
seite.get_by_role("button", name="Anmelden", exact=True).click()
|
||||||
seite.wait_for_timeout(1500)
|
seite.wait_for_timeout(1500)
|
||||||
if sabotage:
|
if sabotage:
|
||||||
pruefe("SABOTAGE: nach der falschen Anmeldung ist die App sichtbar", app_da(), "App nicht sichtbar (so soll es sein)")
|
pruefe("SABOTAGE: nach der falschen Anmeldung ist die App sichtbar", app_da(), "App nicht sichtbar (so soll es sein)")
|
||||||
@@ -122,7 +122,7 @@ def main() -> int:
|
|||||||
time.sleep(rest + 1)
|
time.sleep(rest + 1)
|
||||||
seite.get_by_label("Passwort").fill(zugang["passwort"])
|
seite.get_by_label("Passwort").fill(zugang["passwort"])
|
||||||
seite.get_by_label("Code").fill(totp(zugang["geheimnis"], time.time()))
|
seite.get_by_label("Code").fill(totp(zugang["geheimnis"], time.time()))
|
||||||
seite.get_by_role("button", name="Anmelden").click()
|
seite.get_by_role("button", name="Anmelden", exact=True).click()
|
||||||
pruefe("App sichtbar (Knopf 'Abmelden')", app_da(), "kein Knopf 'Abmelden'")
|
pruefe("App sichtbar (Knopf 'Abmelden')", app_da(), "kein Knopf 'Abmelden'")
|
||||||
pruefe("Benutzername in der Kopfzeile", seite.get_by_text(zugang["benutzer"], exact=True).count() >= 1, "Name fehlt")
|
pruefe("Benutzername in der Kopfzeile", seite.get_by_text(zugang["benutzer"], exact=True).count() >= 1, "Name fehlt")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,929 @@
|
|||||||
|
"""Richtet in Home Assistant den Totmann der Trading Plattform ein (Paket Z2, Anforderung Nr. 7).
|
||||||
|
|
||||||
|
Koordinator-Werkzeug, nicht Teil des Repos. Zwei Teile:
|
||||||
|
- dieses Skript laeuft unter WINDOWS mit dem Python des HA-Projekts (dort liegen websockets,
|
||||||
|
requests, src/ha_verbindung.py, src/config_datei.py und src/vorlage_auswerten.py; Adresse
|
||||||
|
der Instanz und Name der Token-Variable stehen dort, nicht hier) und schreibt nach Home
|
||||||
|
Assistant;
|
||||||
|
- ha_totmann_wsl.py laeuft in WSL mit dem Python der Trading Plattform, liest dort die
|
||||||
|
Geheimnisdatei und den Kalender von Alpaca und gibt Thema, Token und Kalender als eine
|
||||||
|
JSON-Zeile zurueck. Dieses Skript liest sie nur als Pipe (aus_wsl) und gibt davon nichts
|
||||||
|
aus, nur Laengen. Die Geheimnisdatei verlaesst WSL nie.
|
||||||
|
Nichts in diesen Dateien ist ein Geheimnis. Thema und Token landen nur in secrets.yaml von
|
||||||
|
Home Assistant.
|
||||||
|
|
||||||
|
Was der Totmann tut: Eine Automation prueft alle fuenf Minuten, ob das Lebenszeichen der
|
||||||
|
Plattform (input_datetime.trading_gesendet; der Hausmelder schickt es spaetestens alle fuenf
|
||||||
|
Minuten) aelter als 15 Minuten ist -- nur an Handelstagen, von 15 Minuten nach der Oeffnung
|
||||||
|
bis zum Schluss. Dann meldet sie einmal je Stille und Handelstag ueber die HA-App (der Dienst
|
||||||
|
ist das Argument --app-dienst, kein Literal hier) und ueber ntfy (rest_command, Adresse und
|
||||||
|
Zugriff aus der secrets.yaml von Home Assistant).
|
||||||
|
|
||||||
|
Die Bedingung besteht aus vier Eingaben und einer Entscheidung (ENTSCHEIDUNG). Die Automation
|
||||||
|
setzt die Eingaben live (EINGABEN_LIVE); trocken rechnet die Entscheidung mit gestellten
|
||||||
|
Lagen (faelle) lokal, pruefen laesst Home Assistant dieselben Lagen per render_template
|
||||||
|
rechnen. So ist belegt, dass Home Assistant die Entscheidung so versteht wie gemeint.
|
||||||
|
|
||||||
|
Handelstage und Fenster kommen beim Einrichten aus dem Kalender von Alpaca (ueber den
|
||||||
|
WSL-Teil) fuer die naechsten --tage Tage und stehen als Epoch-Sekunden in der Bedingung. Nach
|
||||||
|
dem letzten Datum schweigt der Totmann; der Alias nennt es, "einrichten" verlaengert. Die New
|
||||||
|
Yorker Zeit rechnet das Werkzeug selbst (_ny_versatz_h), weil Python unter Windows ohne das
|
||||||
|
Paket tzdata keine Zeitzonen kennt.
|
||||||
|
|
||||||
|
Schreibwege nach /config: secrets.yaml und configuration.yaml ueber die API des
|
||||||
|
File-editor-Add-ons (src/config_datei.py des HA-Projekts; dessen Pfadregel erlaubt nur
|
||||||
|
themes/, dieses Werkzeug ersetzt sie zur Laufzeit fuer genau diese zwei Dateien). Jeder
|
||||||
|
eigene Block steht zwischen MARKE_ANFANG und MARKE_ENDE; "entfernen" nimmt genau diese
|
||||||
|
Bloecke wieder heraus. Die Automation geht ueber /api/config/automation/config/<id>, wie in
|
||||||
|
ha_einrichten.py.
|
||||||
|
|
||||||
|
Aktionen:
|
||||||
|
pruefen lesend: Dienste, Bloecke (nur ja oder nein), Automation, Alter des
|
||||||
|
Lebenszeichens, die Eingaben heute/jetzt/gesendet live, die juengste Spur der
|
||||||
|
Automation (ob die Bedingung mit this ohne Fehler rechnet) und die Rechenprobe
|
||||||
|
der Bedingung in Home Assistant
|
||||||
|
trocken ohne Netz: baut Automation und Bloecke aus erfundenen Werten, rechnet die
|
||||||
|
gestellten Lagen lokal und prueft die Regeln des WSL-Teils
|
||||||
|
einrichten holt Thema, Token und Kalender aus WSL, schreibt die Bloecke (wenn noetig),
|
||||||
|
prueft die Konfiguration, laedt rest_command neu oder verlangt einen Neustart
|
||||||
|
von Home Assistant (Exit 4) und legt die Automation an oder ersetzt sie
|
||||||
|
probe ruft rest_command und den App-Dienst je einmal -- zwei echte Meldungen
|
||||||
|
entfernen loescht die Automation und nimmt beide Bloecke heraus
|
||||||
|
|
||||||
|
Aufruf: <ha-projekt>\\.venv\\Scripts\\python.exe ha_totmann.py <aktion> [<ha-projektordner>
|
||||||
|
<trading-projektordner>] [--app-dienst notify.<name>] [--tage 400]
|
||||||
|
Exit: 0 fertig, 2 Abbruch oder BEFUND, 3 Voraussetzung fehlt, 4 Neustart von HA noetig.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import tomllib
|
||||||
|
from datetime import date
|
||||||
|
from datetime import datetime
|
||||||
|
from datetime import timedelta
|
||||||
|
from datetime import timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from pathlib import PureWindowsPath
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import ha_totmann_wsl as wsl_teil # noqa: E402
|
||||||
|
|
||||||
|
AUTOMATION_ID = "trading_plattform_totmann"
|
||||||
|
ALIAS = "Trading Plattform: Totmann"
|
||||||
|
LEBENSZEICHEN = "input_datetime.trading_gesendet"
|
||||||
|
REST_DIENST = "trading_totmann_ntfy"
|
||||||
|
SECRET_URL = "trading_totmann_ntfy_url"
|
||||||
|
SECRET_AUTH = "trading_totmann_ntfy_auth"
|
||||||
|
MARKE_ANFANG = "# >>> trading-totmann (ha_totmann.py, Paket Z2)"
|
||||||
|
MARKE_ENDE = "# <<< trading-totmann"
|
||||||
|
ERLAUBT = ("secrets.yaml", "configuration.yaml")
|
||||||
|
STILLE_S = 900
|
||||||
|
# Wie weit die Uhr von Home Assistant von der dieses Rechners abweichen darf, bevor
|
||||||
|
# eingaben_befunde es als Befund nennt.
|
||||||
|
UHR_TOLERANZ_S = 120
|
||||||
|
PRUEFTAKT = "/5"
|
||||||
|
APP_DIENST_MUSTER = re.compile(r"^notify\.[a-z0-9_]+$")
|
||||||
|
FREMDES_REST_COMMAND = re.compile(r"(?m)^rest_command:")
|
||||||
|
WSL = r"C:\Windows\System32\wsl.exe"
|
||||||
|
WSL_PY = "/home/claude/venvs/trading/bin/python"
|
||||||
|
WSL_TEIL = Path(__file__).resolve().with_name("ha_totmann_wsl.py")
|
||||||
|
WSL_TIMEOUT_S = 120
|
||||||
|
TEXT = (
|
||||||
|
"Die Trading Plattform schweigt im Handelsfenster seit mehr als 15 Minuten. Letztes "
|
||||||
|
"Lebenszeichen: {{ states('" + LEBENSZEICHEN + "') }}. Nachsehen: Rechner, WSL, Dashboard."
|
||||||
|
)
|
||||||
|
PROBE_TEXT = "Probe des Totmanns (ha_totmann.py probe): Diese Meldung kam an. Nichts zu tun."
|
||||||
|
# Erfundene Werte fuer trocken. Kein echtes Thema, kein echter Dienst, kein echter Token.
|
||||||
|
TROCKEN_THEMA = "probe-thema-nicht-echt"
|
||||||
|
TROCKEN_TOKEN = "tk_probe_nicht_echt"
|
||||||
|
TROCKEN_DIENST = "notify.probe_dienst"
|
||||||
|
|
||||||
|
# Die vier Eingaben der Bedingung, wie die Automation sie liest. this ist die Automation
|
||||||
|
# selbst; ihr last_triggered ist die letzte Ausloesung.
|
||||||
|
EINGABEN_LIVE = {
|
||||||
|
"heute": "now().strftime('%Y-%m-%d')",
|
||||||
|
"jetzt": "now().timestamp()",
|
||||||
|
"gesendet": "state_attr('" + LEBENSZEICHEN + "', 'timestamp') | float(0)",
|
||||||
|
"zuletzt": "as_timestamp(this.attributes.last_triggered, 0)",
|
||||||
|
}
|
||||||
|
# Die Entscheidung, gemeinsam fuer Automation, trocken und pruefen. Ausloesen, wenn heute
|
||||||
|
# ein Handelstag ist, jetzt im Fenster liegt, das Lebenszeichen aelter als STILLE_S ist und
|
||||||
|
# die letzte Ausloesung vor dem Lebenszeichen lag -- oder vor dem heutigen Fenster: Haelt
|
||||||
|
# eine Stille ueber Nacht an, meldet der neue Handelstag sie einmal.
|
||||||
|
ENTSCHEIDUNG = (
|
||||||
|
"f is not none and f[0] <= jetzt <= f[1] and jetzt - gesendet > "
|
||||||
|
+ str(STILLE_S)
|
||||||
|
+ " and (zuletzt <= gesendet or zuletzt < f[0])"
|
||||||
|
)
|
||||||
|
# Ein erfundener Kalender fuer trocken und die Rechenprobe in pruefen: zwei gewoehnliche Tage
|
||||||
|
# und der verkuerzte Tag nach Thanksgiving 2026.
|
||||||
|
PROBE_KALENDER = [
|
||||||
|
{"date": "2026-09-28", "open": "09:30", "close": "16:00"},
|
||||||
|
{"date": "2026-09-29", "open": "09:30", "close": "16:00"},
|
||||||
|
{"date": "2026-11-27", "open": "09:30", "close": "13:00"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Voraussetzung(Exception):
|
||||||
|
"""Etwas fehlt fuer den Lauf. Der Text traegt keinen Wert aus der Geheimnisdatei."""
|
||||||
|
|
||||||
|
|
||||||
|
def _ny_versatz_h(tag: date) -> int:
|
||||||
|
"""UTC-Versatz der New Yorker Boersenzeit an einem Tag: -4 in der Sommerzeit, sonst -5.
|
||||||
|
|
||||||
|
US-Regel seit 2007: vom zweiten Sonntag im Maerz bis zum ersten Sonntag im November.
|
||||||
|
Umgestellt wird sonntags um 02:00; an einem Handelstag gilt der Versatz des ganzen Tages.
|
||||||
|
"""
|
||||||
|
maerz = date(tag.year, 3, 1)
|
||||||
|
zweiter_sonntag_maerz = maerz + timedelta(days=(6 - maerz.weekday()) % 7 + 7)
|
||||||
|
november = date(tag.year, 11, 1)
|
||||||
|
erster_sonntag_november = november + timedelta(days=(6 - november.weekday()) % 7)
|
||||||
|
return -4 if zweiter_sonntag_maerz <= tag < erster_sonntag_november else -5
|
||||||
|
|
||||||
|
|
||||||
|
def fenster_je_tag(kalender: list[dict]) -> dict[str, list[int]]:
|
||||||
|
"""Je Handelstag [Oeffnung plus STILLE_S, Schluss] als Epoch-Sekunden."""
|
||||||
|
fenster: dict[str, list[int]] = {}
|
||||||
|
for eintrag in kalender:
|
||||||
|
tag = date.fromisoformat(eintrag["date"])
|
||||||
|
zone = timezone(timedelta(hours=_ny_versatz_h(tag)))
|
||||||
|
grenzen = []
|
||||||
|
for feld in ("open", "close"):
|
||||||
|
stunde, minute = (int(teil) for teil in eintrag[feld].split(":"))
|
||||||
|
grenzen.append(
|
||||||
|
int(datetime(tag.year, tag.month, tag.day, stunde, minute, tzinfo=zone).timestamp())
|
||||||
|
)
|
||||||
|
fenster[tag.isoformat()] = [grenzen[0] + STILLE_S, grenzen[1]]
|
||||||
|
return fenster
|
||||||
|
|
||||||
|
|
||||||
|
def gestellt(heute: str, jetzt: int, gesendet: int, zuletzt: int) -> dict[str, str]:
|
||||||
|
"""Gestellte Eingaben als Vorlagen-Literale, fuer die Rechenprobe in pruefen."""
|
||||||
|
return {
|
||||||
|
"heute": "'" + heute + "'",
|
||||||
|
"jetzt": str(jetzt),
|
||||||
|
"gesendet": str(gesendet),
|
||||||
|
"zuletzt": str(zuletzt),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def bedingung(fenster: dict[str, list[int]], eingaben: dict[str, str] | None = None) -> str:
|
||||||
|
"""Die Vorlage der Bedingung; ohne eingaben die der Automation (EINGABEN_LIVE)."""
|
||||||
|
werte = EINGABEN_LIVE if eingaben is None else eingaben
|
||||||
|
return (
|
||||||
|
"{% set fenster = " + json.dumps(fenster, separators=(",", ":")) + " %}"
|
||||||
|
+ "".join(
|
||||||
|
"{% set " + name + " = " + werte[name] + " %}"
|
||||||
|
for name in ("heute", "jetzt", "gesendet", "zuletzt")
|
||||||
|
)
|
||||||
|
+ "{% set f = fenster.get(heute) %}"
|
||||||
|
+ "{{ " + ENTSCHEIDUNG + " }}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ohne_eingaben(vorlage: str) -> str:
|
||||||
|
return re.sub(r"\{% set (heute|jetzt|gesendet|zuletzt) = .*? %\}", "", vorlage)
|
||||||
|
|
||||||
|
|
||||||
|
def _lage(heute: str, jetzt: int, gesendet: int, zuletzt: int) -> dict:
|
||||||
|
return {"heute": heute, "jetzt": jetzt, "gesendet": gesendet, "zuletzt": zuletzt}
|
||||||
|
|
||||||
|
|
||||||
|
def faelle(fenster: dict[str, list[int]]) -> list[tuple[str, dict, bool]]:
|
||||||
|
"""Gestellte Lagen: Name, Eingaben, ob die Bedingung ausloesen soll.
|
||||||
|
|
||||||
|
Montag 28.09. und Dienstag 29.09.2026 aus PROBE_KALENDER, alle Zeiten erfunden. mitte
|
||||||
|
liegt eine Stunde nach dem Beginn des Montagsfensters.
|
||||||
|
"""
|
||||||
|
montag, dienstag = fenster["2026-09-28"], fenster["2026-09-29"]
|
||||||
|
mitte = montag[0] + 3600
|
||||||
|
return [
|
||||||
|
("stille_im_fenster", _lage("2026-09-28", mitte, mitte - 1800, 0), True),
|
||||||
|
("lebenszeichen_frisch", _lage("2026-09-28", mitte, mitte - 300, 0), False),
|
||||||
|
(
|
||||||
|
"schon_gemeldet_nach_dem_letzten_lebenszeichen",
|
||||||
|
_lage("2026-09-28", mitte, mitte - 1800, mitte - 600),
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
("vor_dem_fenster", _lage("2026-09-28", montag[0] - 60, montag[0] - 7200, 0), False),
|
||||||
|
("nach_dem_schluss", _lage("2026-09-28", montag[1] + 600, mitte, 0), False),
|
||||||
|
("kein_handelstag", _lage("2026-09-27", mitte - 86400, 0, 0), False),
|
||||||
|
(
|
||||||
|
"stille_ueber_die_tagesgrenze",
|
||||||
|
_lage("2026-09-29", dienstag[0] + 600, mitte - 1800, mitte - 600),
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"am_neuen_tag_schon_gemeldet",
|
||||||
|
_lage("2026-09-29", dienstag[0] + 1200, mitte - 1800, dienstag[0] + 600),
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _entscheide_lokal(fenster: dict[str, list[int]], lage: dict) -> bool:
|
||||||
|
# ENTSCHEIDUNG ist eine Konstante dieser Datei, kein fremder Text; eval ohne Builtins,
|
||||||
|
# none steht fuer das None der Vorlagensprache.
|
||||||
|
return bool(
|
||||||
|
eval( # noqa: S307
|
||||||
|
ENTSCHEIDUNG,
|
||||||
|
{"__builtins__": {}},
|
||||||
|
{
|
||||||
|
"none": None,
|
||||||
|
"f": fenster.get(lage["heute"]),
|
||||||
|
"jetzt": lage["jetzt"],
|
||||||
|
"gesendet": lage["gesendet"],
|
||||||
|
"zuletzt": lage["zuletzt"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wahrheit(wert: object) -> bool | None:
|
||||||
|
if isinstance(wert, bool):
|
||||||
|
return wert
|
||||||
|
if isinstance(wert, str) and wert.strip().lower() in ("true", "false"):
|
||||||
|
return wert.strip().lower() == "true"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def automation(fenster: dict[str, list[int]], app_dienst: str) -> dict:
|
||||||
|
letzter = max(fenster) if fenster else "keiner"
|
||||||
|
return {
|
||||||
|
"alias": f"{ALIAS} (Handelstage bis {letzter})",
|
||||||
|
"description": (
|
||||||
|
"Meldet ueber die HA-App und ntfy, wenn das Lebenszeichen der Trading Plattform "
|
||||||
|
f"({LEBENSZEICHEN}) im Handelsfenster aelter als 15 Minuten ist; einmal je "
|
||||||
|
f"Stille und Handelstag. Handelstage aus dem Kalender von Alpaca bis {letzter}. "
|
||||||
|
"Angelegt ueber ha_totmann.py (Paket Z2); Rueckbau mit der Aktion entfernen."
|
||||||
|
),
|
||||||
|
"trigger": [{"platform": "time_pattern", "minutes": PRUEFTAKT}],
|
||||||
|
"condition": [{"condition": "template", "value_template": bedingung(fenster)}],
|
||||||
|
"action": [
|
||||||
|
{
|
||||||
|
"service": f"rest_command.{REST_DIENST}",
|
||||||
|
"data": {"nachricht": TEXT},
|
||||||
|
"continue_on_error": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service": app_dienst,
|
||||||
|
"data": {"title": "Trading Totmann", "message": TEXT},
|
||||||
|
"continue_on_error": True,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"mode": "single",
|
||||||
|
"max_exceeded": "silent",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def config_block(mit_token: bool) -> str:
|
||||||
|
"""Der Block fuer configuration.yaml. Kein Thema: Adresse und Zugriff kommen per !secret."""
|
||||||
|
zeilen = [
|
||||||
|
MARKE_ANFANG,
|
||||||
|
"# Totmann der Trading Plattform: Meldung ueber ntfy. Adresse und Zugriff stehen in",
|
||||||
|
"# secrets.yaml; hier steht kein Thema.",
|
||||||
|
"rest_command:",
|
||||||
|
f" {REST_DIENST}:",
|
||||||
|
f" url: !secret {SECRET_URL}",
|
||||||
|
" method: POST",
|
||||||
|
' content_type: "text/plain; charset=utf-8"',
|
||||||
|
" timeout: 10",
|
||||||
|
' payload: "{{ nachricht }}"',
|
||||||
|
" headers:",
|
||||||
|
' Title: "Trading Totmann"',
|
||||||
|
' Priority: "high"',
|
||||||
|
' Tags: "rotating_light"',
|
||||||
|
]
|
||||||
|
if mit_token:
|
||||||
|
zeilen.append(f" Authorization: !secret {SECRET_AUTH}")
|
||||||
|
zeilen.append(MARKE_ENDE)
|
||||||
|
return "\n".join(zeilen) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def secrets_block(url: str, token: str) -> str:
|
||||||
|
"""Der Block fuer secrets.yaml: die ganze Adresse mit Thema, dazu der Zugriff."""
|
||||||
|
zeilen = [MARKE_ANFANG, f'{SECRET_URL}: "{url}"']
|
||||||
|
if token:
|
||||||
|
zeilen.append(f'{SECRET_AUTH}: "Bearer {token}"')
|
||||||
|
zeilen.append(MARKE_ENDE)
|
||||||
|
return "\n".join(zeilen) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def ohne_block(text: str) -> str:
|
||||||
|
"""Der Text ohne den eigenen Block zwischen den Marken, mit genau einem Umbruch am Ende."""
|
||||||
|
muster = re.compile(
|
||||||
|
r"\n*" + re.escape(MARKE_ANFANG) + r".*?" + re.escape(MARKE_ENDE) + r"\n?", re.DOTALL
|
||||||
|
)
|
||||||
|
rest = muster.sub("\n", text).rstrip("\n")
|
||||||
|
return rest + "\n" if rest else ""
|
||||||
|
|
||||||
|
|
||||||
|
def anhaengen(text: str, block: str) -> str:
|
||||||
|
rest = text.rstrip("\n")
|
||||||
|
return (rest + "\n\n" if rest else "") + block
|
||||||
|
|
||||||
|
|
||||||
|
def _ohne(text: str, *geheim: str) -> str:
|
||||||
|
for wert in geheim:
|
||||||
|
if wert:
|
||||||
|
text = text.replace(wert, "***")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _wsl_pfad(pfad: str) -> str:
|
||||||
|
"""Ein Windows-Pfad mit Laufwerksbuchstaben als Pfad unter /mnt in WSL."""
|
||||||
|
windows = PureWindowsPath(pfad)
|
||||||
|
if len(windows.drive) != 2 or not windows.drive.endswith(":"):
|
||||||
|
raise Voraussetzung(f"kein Laufwerkspfad: {pfad}")
|
||||||
|
return "/mnt/" + windows.drive[0].lower() + "/" + "/".join(windows.parts[1:])
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_wsl_ausgabe(daten: object) -> dict:
|
||||||
|
"""Die Zeile des WSL-Teils, nach denselben Regeln geprueft, die er selbst anlegt."""
|
||||||
|
if not isinstance(daten, dict) or set(daten) != {"thema", "token", "kalender"}:
|
||||||
|
raise Voraussetzung("WSL-Teil: Ausgabe in unerwarteter Form")
|
||||||
|
thema, token = wsl_teil.zugang({"ntfy": {"thema": daten["thema"], "token": daten["token"]}})
|
||||||
|
return {"thema": thema, "token": token, "kalender": wsl_teil.pruefe_kalender(daten["kalender"])}
|
||||||
|
|
||||||
|
|
||||||
|
def aus_wsl(tage: int) -> dict:
|
||||||
|
"""Startet den WSL-Teil und liest seine eine Zeile als Pipe. stdout wird nie ausgegeben."""
|
||||||
|
lauf = subprocess.run(
|
||||||
|
[
|
||||||
|
WSL, "-d", "Debian", "-u", "claude", "--exec",
|
||||||
|
WSL_PY, _wsl_pfad(str(WSL_TEIL)), "--tage", str(tage),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
timeout=WSL_TIMEOUT_S,
|
||||||
|
)
|
||||||
|
if lauf.returncode != 0:
|
||||||
|
# stderr des WSL-Teils traegt nur Befunde ohne Werte.
|
||||||
|
fehler = lauf.stderr.decode("utf-8", errors="replace").replace("\x00", "").strip()
|
||||||
|
raise Voraussetzung(
|
||||||
|
f"WSL-Teil endete mit Exit {lauf.returncode}: {fehler[:300] or 'ohne Meldung'}"
|
||||||
|
)
|
||||||
|
zeilen = [
|
||||||
|
zeile
|
||||||
|
for zeile in lauf.stdout.decode("utf-8", errors="replace").split("\n")
|
||||||
|
if zeile.startswith("{")
|
||||||
|
]
|
||||||
|
if len(zeilen) != 1:
|
||||||
|
raise Voraussetzung(f"WSL-Teil: {len(zeilen)} statt genau einer JSON-Zeile")
|
||||||
|
return pruefe_wsl_ausgabe(json.loads(zeilen[0]))
|
||||||
|
|
||||||
|
|
||||||
|
def trocken() -> int:
|
||||||
|
"""Prueft die eigenen Bausteine ohne Netz, mit erfundenen Werten."""
|
||||||
|
befunde = []
|
||||||
|
for tag, erwartet in (
|
||||||
|
(date(2026, 3, 6), -5),
|
||||||
|
(date(2026, 3, 9), -4),
|
||||||
|
(date(2026, 10, 30), -4),
|
||||||
|
(date(2026, 11, 2), -5),
|
||||||
|
(date(2027, 3, 12), -5),
|
||||||
|
(date(2027, 3, 15), -4),
|
||||||
|
):
|
||||||
|
if _ny_versatz_h(tag) != erwartet:
|
||||||
|
befunde.append(f"Versatz am {tag}: {_ny_versatz_h(tag)} statt {erwartet}")
|
||||||
|
fenster = fenster_je_tag(PROBE_KALENDER)
|
||||||
|
# 09:45 EDT ist 13:45 UTC, 16:00 EDT 20:00 UTC; 09:45 EST ist 14:45 UTC, 13:00 EST 18:00 UTC.
|
||||||
|
for tag, soll in (("2026-09-28", ["13:45", "20:00"]), ("2026-11-27", ["14:45", "18:00"])):
|
||||||
|
ist = [datetime.fromtimestamp(s, timezone.utc).strftime("%H:%M") for s in fenster[tag]]
|
||||||
|
if ist != soll:
|
||||||
|
befunde.append(f"Fenster am {tag}: {ist} statt {soll}")
|
||||||
|
# Die Bedingung: jede gestellte Lage lokal, mit derselben ENTSCHEIDUNG wie in HA.
|
||||||
|
lagen = faelle(fenster)
|
||||||
|
for name, lage, soll in lagen:
|
||||||
|
if _entscheide_lokal(fenster, lage) is not soll:
|
||||||
|
befunde.append(f"Bedingung {name}: soll {'ausloesen' if soll else 'schweigen'}")
|
||||||
|
live = bedingung(fenster)
|
||||||
|
for name, ausdruck in EINGABEN_LIVE.items():
|
||||||
|
if "{% set " + name + " = " + ausdruck + " %}" not in live:
|
||||||
|
befunde.append(f"die Automation liest {name} nicht wie vorgesehen")
|
||||||
|
if _ohne_eingaben(live) != _ohne_eingaben(bedingung(fenster, gestellt(**lagen[0][1]))):
|
||||||
|
befunde.append("die Rechenprobe entschiede mit einem anderen Ausdruck als die Automation")
|
||||||
|
roh = json.dumps(automation(fenster, TROCKEN_DIENST))
|
||||||
|
anders = json.dumps(automation(fenster, "notify.anderer_dienst"))
|
||||||
|
konf = config_block(True)
|
||||||
|
geheim = secrets_block("https://ntfy.example/" + TROCKEN_THEMA, TROCKEN_TOKEN)
|
||||||
|
if TROCKEN_THEMA in roh or TROCKEN_THEMA in konf:
|
||||||
|
befunde.append("das Thema stuende in der Automation oder in configuration.yaml")
|
||||||
|
if TROCKEN_TOKEN in roh or TROCKEN_TOKEN in konf:
|
||||||
|
befunde.append("der Token stuende in der Automation oder in configuration.yaml")
|
||||||
|
if TROCKEN_THEMA not in geheim or f"Bearer {TROCKEN_TOKEN}" not in geheim:
|
||||||
|
befunde.append("Thema oder Token fehlen im Block fuer secrets.yaml")
|
||||||
|
if roh.count(TROCKEN_DIENST) != 1 or "notify.anderer_dienst" not in anders:
|
||||||
|
befunde.append("der App-Dienst kommt nicht aus dem Argument")
|
||||||
|
if TROCKEN_DIENST in anders:
|
||||||
|
befunde.append("der App-Dienst steht fest in der Automation")
|
||||||
|
if f"!secret {SECRET_URL}" not in konf or f"!secret {SECRET_AUTH}" not in konf:
|
||||||
|
befunde.append("configuration.yaml nennt die Geheimnisse nicht per !secret")
|
||||||
|
if f"!secret {SECRET_AUTH}" in config_block(False):
|
||||||
|
befunde.append("ohne Token verwiese configuration.yaml auf ein fehlendes Geheimnis")
|
||||||
|
if ohne_block(anhaengen("a: 1\n", konf)) != "a: 1\n":
|
||||||
|
befunde.append("entfernen liesse vom Block etwas stehen")
|
||||||
|
if ohne_block(konf) != "":
|
||||||
|
befunde.append("eine Datei nur mit dem Block bliebe nicht leer")
|
||||||
|
if not FREMDES_REST_COMMAND.search(ohne_block("x: 1\nrest_command:\n y: 2\n")):
|
||||||
|
befunde.append("ein fremdes rest_command wuerde uebersehen")
|
||||||
|
if FREMDES_REST_COMMAND.search(ohne_block(anhaengen("x: 1\n", konf))):
|
||||||
|
befunde.append("der eigene Block gaelte als fremdes rest_command")
|
||||||
|
# Der WSL-Teil, ohne ihn zu starten: seine Regeln und der Weg ueber die Pipe.
|
||||||
|
if _wsl_pfad(r"D:\Claude\.vault-tools\x\y.py") != "/mnt/d/Claude/.vault-tools/x/y.py":
|
||||||
|
befunde.append("der Pfad fuer WSL ist falsch umgesetzt")
|
||||||
|
try:
|
||||||
|
wsl_teil.zugang({"ntfy": {"thema": "kein thema!", "token": ""}})
|
||||||
|
befunde.append("ein Thema ausserhalb der Regel ginge durch")
|
||||||
|
except wsl_teil.Befund as befund:
|
||||||
|
if "kein thema" in str(befund):
|
||||||
|
befunde.append("der Befund des WSL-Teils nennt den Wert")
|
||||||
|
try:
|
||||||
|
wsl_teil.pruefe_kalender([{"date": "2026-09-28", "open": "09:30", "close": "4pm"}])
|
||||||
|
befunde.append("ein Kalender mit falscher Uhrzeit ginge durch")
|
||||||
|
except wsl_teil.Befund:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
zurueck = pruefe_wsl_ausgabe(
|
||||||
|
json.loads(wsl_teil.ausgabe(TROCKEN_THEMA, TROCKEN_TOKEN, PROBE_KALENDER))
|
||||||
|
)
|
||||||
|
if zurueck != {"thema": TROCKEN_THEMA, "token": TROCKEN_TOKEN, "kalender": PROBE_KALENDER}:
|
||||||
|
befunde.append("die Zeile des WSL-Teils kommt nicht unveraendert an")
|
||||||
|
except (Voraussetzung, wsl_teil.Befund) as fehler:
|
||||||
|
befunde.append(f"die Zeile des WSL-Teils wird abgelehnt: {fehler}")
|
||||||
|
try:
|
||||||
|
pruefe_wsl_ausgabe({"thema": TROCKEN_THEMA})
|
||||||
|
befunde.append("eine unvollstaendige Zeile des WSL-Teils ginge durch")
|
||||||
|
except Voraussetzung:
|
||||||
|
pass
|
||||||
|
# Die Plausibilitaet der Live-Eingaben, mit gestellten Antworten: Mittag in Berlin.
|
||||||
|
jetzt_probe = datetime(2026, 9, 28, 10, 0, tzinfo=timezone.utc).timestamp()
|
||||||
|
gut = {
|
||||||
|
"heute": datetime.fromtimestamp(jetzt_probe).strftime("%Y-%m-%d"),
|
||||||
|
"jetzt": jetzt_probe,
|
||||||
|
"gesendet": jetzt_probe - 100,
|
||||||
|
}
|
||||||
|
if eingaben_befunde(gut, jetzt_probe - 100, jetzt_probe):
|
||||||
|
befunde.append("plausible Live-Eingaben gaelten als Befund")
|
||||||
|
for name, schlecht, zeichen in (
|
||||||
|
("gesendet_null", dict(gut, gesendet=0.0), None),
|
||||||
|
("uhr_daneben", dict(gut, jetzt=jetzt_probe + 600), jetzt_probe - 100),
|
||||||
|
("falscher_tag", dict(gut, heute="2026-09-27"), jetzt_probe - 100),
|
||||||
|
("anderer_stempel", gut, jetzt_probe - 5000),
|
||||||
|
("jetzt_kein_wert", dict(gut, jetzt=None), jetzt_probe - 100),
|
||||||
|
):
|
||||||
|
if not eingaben_befunde(schlecht, zeichen, jetzt_probe):
|
||||||
|
befunde.append(f"Live-Eingaben {name} gingen durch")
|
||||||
|
# Die Spur, mit gestellten Antworten von trace/list und trace/get.
|
||||||
|
in_ordnung = {"timestamp": {"start": "x"}, "trace": {"condition/0": [{"result": {"result": False}}]}}
|
||||||
|
for name, liste, lauf, soll in (
|
||||||
|
("ohne_lauf", [], None, False),
|
||||||
|
("in_ordnung", [{"run_id": "1"}], in_ordnung, False),
|
||||||
|
("renderfehler", [{"run_id": "1"}], {"trace": {"condition/0": [{"error": "UndefinedError"}]}}, True),
|
||||||
|
("ohne_bedingung", [{"run_id": "1"}], {"trace": {}}, True),
|
||||||
|
("ohne_wahrheitswert", [{"run_id": "1"}], {"trace": {"condition/0": [{"result": {}}]}}, True),
|
||||||
|
):
|
||||||
|
if spur_befund(liste, lauf)[1] is not soll:
|
||||||
|
befunde.append(f"Spur {name}: soll {'Befund' if soll else 'kein Befund'}")
|
||||||
|
for befund in befunde:
|
||||||
|
print(f"BEFUND {befund}")
|
||||||
|
print(
|
||||||
|
f"trocken: {len(fenster)} Tage, {len(lagen)} Lagen, Automation {len(roh)} Zeichen, "
|
||||||
|
f"{len(befunde)} Befund(e)"
|
||||||
|
)
|
||||||
|
return 2 if befunde else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _nur_zwei_dateien(pfad: object, *, datei: bool) -> str:
|
||||||
|
"""Ersetzt config_datei.pruefe_pfad fuer dieses Werkzeug: genau zwei Dateien in /config."""
|
||||||
|
from config_datei import PfadVerboten
|
||||||
|
|
||||||
|
if pfad not in ERLAUBT:
|
||||||
|
raise PfadVerboten(f"Pfad {pfad!r} ist keine der Dateien {ERLAUBT}")
|
||||||
|
return str(pfad)
|
||||||
|
|
||||||
|
|
||||||
|
def _dienste(ha) -> dict:
|
||||||
|
return ha.befehl_ok("get_services") or {}
|
||||||
|
|
||||||
|
|
||||||
|
def bedingung_pruefen(ha) -> int:
|
||||||
|
"""Die Rechenprobe: Home Assistant rechnet jede gestellte Lage per render_template.
|
||||||
|
|
||||||
|
Lesend (render_template steht in der Leseliste des HA-Projekts). Gerechnet wird mit dem
|
||||||
|
erfundenen PROBE_KALENDER und derselben ENTSCHEIDUNG wie in der Automation; nur die vier
|
||||||
|
Eingaben sind gestellt. Zurueck kommt die Zahl der Befunde.
|
||||||
|
"""
|
||||||
|
from vorlage_auswerten import auswerten
|
||||||
|
|
||||||
|
fenster = fenster_je_tag(PROBE_KALENDER)
|
||||||
|
befunde = 0
|
||||||
|
for name, lage, soll in faelle(fenster):
|
||||||
|
nachrichten = ha.roh_sammeln(
|
||||||
|
"render_template",
|
||||||
|
5.0,
|
||||||
|
template=bedingung(fenster, gestellt(**lage)),
|
||||||
|
strict=True,
|
||||||
|
report_errors=True,
|
||||||
|
)
|
||||||
|
art, wert = auswerten(nachrichten)
|
||||||
|
ist = _wahrheit(wert) if art == "ERGEBNIS" else None
|
||||||
|
zeile = (
|
||||||
|
f"Bedingung {name}: soll {'ausloesen' if soll else 'schweigen'}, "
|
||||||
|
f"Home Assistant: {art} {wert!r}"
|
||||||
|
)
|
||||||
|
if ist is soll:
|
||||||
|
print(zeile)
|
||||||
|
else:
|
||||||
|
befunde += 1
|
||||||
|
print("BEFUND " + zeile)
|
||||||
|
return befunde
|
||||||
|
|
||||||
|
|
||||||
|
def eingaben_befunde(werte: dict, zeichen_ha: object, jetzt_hier: float) -> list[str]:
|
||||||
|
"""Plausibilitaet der drei live gerechneten Eingaben ohne this (Nachkritik 2026-09-24).
|
||||||
|
|
||||||
|
werte: heute, jetzt und gesendet, wie render_template sie liefert. zeichen_ha: das
|
||||||
|
Attribut timestamp des Lebenszeichens aus get_states. jetzt_hier: time.time() dieses
|
||||||
|
Rechners; er steht in derselben Zeitzone wie Home Assistant (Berlin).
|
||||||
|
"""
|
||||||
|
befunde = []
|
||||||
|
try:
|
||||||
|
jetzt = float(werte["jetzt"])
|
||||||
|
except (TypeError, ValueError, KeyError):
|
||||||
|
return [f"jetzt ist keine Zahl: {werte.get('jetzt')!r}"]
|
||||||
|
if abs(jetzt - jetzt_hier) > UHR_TOLERANZ_S:
|
||||||
|
befunde.append(f"jetzt weicht {jetzt - jetzt_hier:+.0f} s von der Uhr dieses Rechners ab")
|
||||||
|
heute_hier = datetime.fromtimestamp(jetzt).strftime("%Y-%m-%d")
|
||||||
|
if str(werte.get("heute")) != heute_hier:
|
||||||
|
befunde.append(f"heute ist {werte.get('heute')!r}, nach jetzt in Ortszeit {heute_hier}")
|
||||||
|
try:
|
||||||
|
gesendet = float(werte["gesendet"])
|
||||||
|
except (TypeError, ValueError, KeyError):
|
||||||
|
return befunde + [f"gesendet ist keine Zahl: {werte.get('gesendet')!r}"]
|
||||||
|
if gesendet <= 0.0:
|
||||||
|
befunde.append("gesendet ist 0: das Attribut timestamp des Lebenszeichens fehlt oder ist leer")
|
||||||
|
elif not isinstance(zeichen_ha, (int, float)) or abs(gesendet - float(zeichen_ha)) > 1.0:
|
||||||
|
befunde.append(f"gesendet ist nicht der Zeitstempel des Lebenszeichens ({zeichen_ha!r})")
|
||||||
|
elif gesendet > jetzt + UHR_TOLERANZ_S:
|
||||||
|
befunde.append("gesendet liegt in der Zukunft")
|
||||||
|
return befunde
|
||||||
|
|
||||||
|
|
||||||
|
def eingaben_pruefen(ha, zeichen_ha: object) -> int:
|
||||||
|
"""Rechnet heute, jetzt und gesendet live per render_template, wie die Automation sie liest.
|
||||||
|
|
||||||
|
zuletzt liest this und ist nur im Lauf der Automation definiert; das belegt spur_pruefen.
|
||||||
|
Zurueck kommt die Zahl der Befunde.
|
||||||
|
"""
|
||||||
|
from vorlage_auswerten import auswerten
|
||||||
|
|
||||||
|
werte: dict[str, object] = {}
|
||||||
|
for name in ("heute", "jetzt", "gesendet"):
|
||||||
|
nachrichten = ha.roh_sammeln(
|
||||||
|
"render_template",
|
||||||
|
5.0,
|
||||||
|
template="{{ " + EINGABEN_LIVE[name] + " }}",
|
||||||
|
strict=True,
|
||||||
|
report_errors=True,
|
||||||
|
)
|
||||||
|
art, wert = auswerten(nachrichten)
|
||||||
|
if art != "ERGEBNIS":
|
||||||
|
print(f"BEFUND Eingabe {name}: Home Assistant: {art} {wert!r}")
|
||||||
|
return 1
|
||||||
|
werte[name] = wert
|
||||||
|
befunde = eingaben_befunde(werte, zeichen_ha, time.time())
|
||||||
|
for befund in befunde:
|
||||||
|
print(f"BEFUND Eingabe {befund}")
|
||||||
|
if not befunde:
|
||||||
|
abstand = float(werte["jetzt"]) - float(werte["gesendet"])
|
||||||
|
print(f"Eingaben live: heute {werte['heute']}, jetzt und gesendet plausibel (Abstand {abstand:.0f} s)")
|
||||||
|
return len(befunde)
|
||||||
|
|
||||||
|
|
||||||
|
def spur_befund(liste: object, lauf: object) -> tuple[str, bool]:
|
||||||
|
"""Die juengste Spur der Automation: (Zeile, ob Befund).
|
||||||
|
|
||||||
|
liste ist die Antwort von trace/list, lauf die von trace/get fuer deren juengsten Lauf.
|
||||||
|
Belegt, was render_template nicht kann: dass die Bedingung im Lauf der Automation mit
|
||||||
|
this rechnet (Home Assistant 2026.9.2: automation/__init__.py:707-711 setzt this,
|
||||||
|
helpers/trace.py:113-118 schreibt error oder result in den Schritt).
|
||||||
|
"""
|
||||||
|
if not isinstance(liste, list) or not liste:
|
||||||
|
return "Spur: noch kein Lauf (die Automation prueft alle fuenf Minuten)", False
|
||||||
|
if not isinstance(lauf, dict) or not isinstance(lauf.get("trace"), dict):
|
||||||
|
return f"Spur: Form unbekannt ({type(lauf).__name__})", True
|
||||||
|
schritte = lauf["trace"].get("condition/0")
|
||||||
|
if not isinstance(schritte, list) or not schritte or not isinstance(schritte[0], dict):
|
||||||
|
return "Spur: kein Schritt condition/0 im juengsten Lauf", True
|
||||||
|
schritt = schritte[0]
|
||||||
|
if "error" in schritt:
|
||||||
|
return f"Spur: Bedingung mit Fehler: {str(schritt['error'])[:200]}", True
|
||||||
|
ergebnis = schritt.get("result")
|
||||||
|
wert = ergebnis.get("result") if isinstance(ergebnis, dict) else None
|
||||||
|
if not isinstance(wert, bool):
|
||||||
|
return f"Spur: Bedingung ohne Wahrheitswert ({str(ergebnis)[:200]})", True
|
||||||
|
stempel = lauf.get("timestamp")
|
||||||
|
start = stempel.get("start", "?") if isinstance(stempel, dict) else "?"
|
||||||
|
return f"Spur: juengster Lauf {start}, Bedingung {wert}, kein Fehler", False
|
||||||
|
|
||||||
|
|
||||||
|
def spur_pruefen(ha) -> int:
|
||||||
|
"""Liest die juengste Spur der Automation (trace/list, trace/get). 1 bei Befund."""
|
||||||
|
liste = ha.befehl_ok("trace/list", domain="automation", item_id=AUTOMATION_ID)
|
||||||
|
lauf = None
|
||||||
|
laeufe = [eintrag for eintrag in liste or [] if isinstance(eintrag, dict)]
|
||||||
|
if laeufe:
|
||||||
|
juengster = max(
|
||||||
|
laeufe, key=lambda eintrag: str((eintrag.get("timestamp") or {}).get("start", ""))
|
||||||
|
)
|
||||||
|
lauf = ha.befehl_ok(
|
||||||
|
"trace/get", domain="automation", item_id=AUTOMATION_ID, run_id=juengster.get("run_id")
|
||||||
|
)
|
||||||
|
zeile, befund = spur_befund(laeufe, lauf)
|
||||||
|
print(("BEFUND " if befund else "") + zeile)
|
||||||
|
return 1 if befund else 0
|
||||||
|
|
||||||
|
|
||||||
|
def pruefen(ha, datei) -> int:
|
||||||
|
dienste = _dienste(ha)
|
||||||
|
rest = dienste.get("rest_command", {})
|
||||||
|
print(f"rest_command.{REST_DIENST}: {'vorhanden' if REST_DIENST in rest else 'fehlt'}")
|
||||||
|
print(f"rest_command.reload: {'vorhanden' if 'reload' in rest else 'fehlt'}")
|
||||||
|
app = sorted(name for name in dienste.get("notify", {}) if name.startswith("mobile_app_"))
|
||||||
|
print("notify-Dienste der App: " + (", ".join(f"notify.{n}" for n in app) or "keiner"))
|
||||||
|
for name in ERLAUBT:
|
||||||
|
inhalt = datei.lies(name)
|
||||||
|
text = "" if inhalt is None else inhalt.decode("utf-8")
|
||||||
|
zeile = f"{name}: {'fehlt' if inhalt is None else 'vorhanden'}, Block des Totmanns "
|
||||||
|
zeile += "da" if MARKE_ANFANG in text else "nicht da"
|
||||||
|
if name == "configuration.yaml":
|
||||||
|
fremd = FREMDES_REST_COMMAND.search(ohne_block(text))
|
||||||
|
zeile += f", fremdes rest_command {'ja' if fremd else 'nein'}"
|
||||||
|
print(zeile)
|
||||||
|
zustaende = ha.befehl_ok("get_states") or []
|
||||||
|
auto = [s for s in zustaende if s.get("attributes", {}).get("id") == AUTOMATION_ID]
|
||||||
|
if auto:
|
||||||
|
print(
|
||||||
|
f"Automation: {auto[0]['entity_id']} = {auto[0]['state']}, "
|
||||||
|
f"{auto[0]['attributes'].get('friendly_name')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("Automation: fehlt")
|
||||||
|
zeichen = [s for s in zustaende if s.get("entity_id") == LEBENSZEICHEN]
|
||||||
|
stempel = zeichen[0].get("attributes", {}).get("timestamp") if zeichen else None
|
||||||
|
if isinstance(stempel, (int, float)):
|
||||||
|
print(f"Lebenszeichen: vor {time.time() - stempel:.0f} s")
|
||||||
|
else:
|
||||||
|
print("Lebenszeichen: nicht lesbar")
|
||||||
|
live = eingaben_pruefen(ha, stempel)
|
||||||
|
if auto:
|
||||||
|
live += spur_pruefen(ha)
|
||||||
|
befunde = bedingung_pruefen(ha)
|
||||||
|
print(f"Rechenprobe der Bedingung: {len(faelle(fenster_je_tag(PROBE_KALENDER)))} Lagen, {befunde} Befund(e)")
|
||||||
|
return 2 if befunde or live else 0
|
||||||
|
|
||||||
|
|
||||||
|
def einrichten(ha, datei, trading: Path, app_dienst: str, zugang: dict) -> int:
|
||||||
|
dienste = _dienste(ha)
|
||||||
|
if app_dienst.split(".", 1)[1] not in dienste.get("notify", {}):
|
||||||
|
print(f"ABBRUCH: {app_dienst} steht nicht in der Dienstliste von Home Assistant")
|
||||||
|
return 3
|
||||||
|
thema, zugriff = zugang["thema"], zugang["token"]
|
||||||
|
paper = tomllib.loads((trading / "config" / "paper.toml").read_text(encoding="utf-8"))
|
||||||
|
ntfy_url = str(paper["benachrichtigung"]["ntfy_url"]).rstrip("/")
|
||||||
|
fenster = fenster_je_tag(zugang["kalender"])
|
||||||
|
if not fenster:
|
||||||
|
print("ABBRUCH: Der Kalender von Alpaca nennt keinen Handelstag")
|
||||||
|
return 3
|
||||||
|
print(f"Kalender: {len(fenster)} Handelstage, {min(fenster)} bis {max(fenster)}")
|
||||||
|
|
||||||
|
alt = {name: datei.lies(name) for name in ERLAUBT}
|
||||||
|
if alt["configuration.yaml"] is None:
|
||||||
|
print("ABBRUCH: configuration.yaml nicht lesbar")
|
||||||
|
return 3
|
||||||
|
konf_alt = alt["configuration.yaml"].decode("utf-8")
|
||||||
|
geh_alt = (alt["secrets.yaml"] or b"").decode("utf-8")
|
||||||
|
if FREMDES_REST_COMMAND.search(ohne_block(konf_alt)):
|
||||||
|
print("ABBRUCH: configuration.yaml hat schon einen Abschnitt rest_command; von Hand zusammenfuehren")
|
||||||
|
return 3
|
||||||
|
neu = {
|
||||||
|
"secrets.yaml": anhaengen(
|
||||||
|
ohne_block(geh_alt), secrets_block(f"{ntfy_url}/{thema}", zugriff)
|
||||||
|
),
|
||||||
|
"configuration.yaml": anhaengen(ohne_block(konf_alt), config_block(bool(zugriff))),
|
||||||
|
}
|
||||||
|
vorher = {"secrets.yaml": geh_alt, "configuration.yaml": konf_alt}
|
||||||
|
geschrieben = []
|
||||||
|
for name in ERLAUBT:
|
||||||
|
if neu[name] != vorher[name]:
|
||||||
|
datei.schreibe_und_pruefe(name, neu[name].encode("utf-8"))
|
||||||
|
geschrieben.append(name)
|
||||||
|
status, antwort = ha.http_post("/api/config/core/check_config", {})
|
||||||
|
ergebnis = antwort.get("result") if isinstance(antwort, dict) else None
|
||||||
|
if status != 200 or ergebnis != "valid":
|
||||||
|
fehler = antwort.get("errors") if isinstance(antwort, dict) else antwort
|
||||||
|
print("BEFUND Konfiguration ungueltig: " + _ohne(str(fehler), thema, zugriff)[:300])
|
||||||
|
for name in geschrieben:
|
||||||
|
datei.schreibe_und_pruefe(name, alt[name] or b"")
|
||||||
|
print(f"Zurueckgeschrieben: {geschrieben}")
|
||||||
|
return 2
|
||||||
|
print(f"Geschrieben: {geschrieben or 'nichts, die Bloecke standen schon da'}; Konfiguration gueltig")
|
||||||
|
|
||||||
|
rest = _dienste(ha).get("rest_command", {})
|
||||||
|
if REST_DIENST not in rest and "reload" in rest:
|
||||||
|
ha.befehl_ok("call_service", domain="rest_command", service="reload", service_data={})
|
||||||
|
rest = _dienste(ha).get("rest_command", {})
|
||||||
|
if REST_DIENST not in rest:
|
||||||
|
print(
|
||||||
|
f"rest_command.{REST_DIENST} ist noch nicht geladen: Home Assistant neu starten "
|
||||||
|
"(Einstellungen, System, Neu starten), danach einrichten noch einmal -- die Bloecke "
|
||||||
|
"stehen dann schon da."
|
||||||
|
)
|
||||||
|
return 4
|
||||||
|
|
||||||
|
status, antwort = ha.http_post(
|
||||||
|
f"/api/config/automation/config/{AUTOMATION_ID}", automation(fenster, app_dienst)
|
||||||
|
)
|
||||||
|
print(f"Automation angelegt oder ersetzt: HTTP {status}")
|
||||||
|
if status != 200:
|
||||||
|
print("ABBRUCH: Die Automation wurde nicht angenommen: " + _ohne(str(antwort), thema, zugriff)[:200])
|
||||||
|
return 2
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def probe(ha, app_dienst: str) -> int:
|
||||||
|
dienste = _dienste(ha)
|
||||||
|
if REST_DIENST not in dienste.get("rest_command", {}):
|
||||||
|
print(f"ABBRUCH: rest_command.{REST_DIENST} fehlt; erst einrichten")
|
||||||
|
return 3
|
||||||
|
name = app_dienst.split(".", 1)[1]
|
||||||
|
if name not in dienste.get("notify", {}):
|
||||||
|
print(f"ABBRUCH: {app_dienst} steht nicht in der Dienstliste von Home Assistant")
|
||||||
|
return 3
|
||||||
|
ha.befehl_ok(
|
||||||
|
"call_service", domain="rest_command", service=REST_DIENST, service_data={"nachricht": PROBE_TEXT}
|
||||||
|
)
|
||||||
|
ha.befehl_ok(
|
||||||
|
"call_service",
|
||||||
|
domain="notify",
|
||||||
|
service=name,
|
||||||
|
service_data={"title": "Trading Totmann (Probe)", "message": PROBE_TEXT},
|
||||||
|
)
|
||||||
|
print("Zwei Probemeldungen abgeschickt (ntfy und HA-App). Ob sie ankamen, sieht nur Pascal.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def entfernen(ha, datei) -> int:
|
||||||
|
status = ha.http_delete(f"/api/config/automation/config/{AUTOMATION_ID}")
|
||||||
|
print(f"Automation geloescht: HTTP {status}")
|
||||||
|
for name in ERLAUBT:
|
||||||
|
inhalt = datei.lies(name)
|
||||||
|
if inhalt is None:
|
||||||
|
print(f"{name}: fehlt")
|
||||||
|
continue
|
||||||
|
text = inhalt.decode("utf-8")
|
||||||
|
if MARKE_ANFANG not in text:
|
||||||
|
print(f"{name}: kein Block")
|
||||||
|
continue
|
||||||
|
datei.schreibe_und_pruefe(name, ohne_block(text).encode("utf-8"))
|
||||||
|
print(f"{name}: Block entfernt")
|
||||||
|
status, antwort = ha.http_post("/api/config/core/check_config", {})
|
||||||
|
print("Konfiguration: " + str(antwort.get("result") if isinstance(antwort, dict) else f"HTTP {status}"))
|
||||||
|
rest = _dienste(ha).get("rest_command", {})
|
||||||
|
if "reload" in rest:
|
||||||
|
ha.befehl_ok("call_service", domain="rest_command", service="reload", service_data={})
|
||||||
|
print("rest_command neu geladen")
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"rest_command.{REST_DIENST} bleibt bis zum naechsten Neustart von Home Assistant "
|
||||||
|
"geladen; ohne Automation ruft ihn niemand."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# Die Befehle, die pruefen an Home Assistant schickt. Alle lesen nur.
|
||||||
|
LESEND = frozenset({"get_services", "get_states", "trace/list", "trace/get", "render_template"})
|
||||||
|
|
||||||
|
|
||||||
|
class _NurLesend:
|
||||||
|
"""Die Verbindung fuer pruefen: laesst nur LESEND und das Lesen von Dateien durch.
|
||||||
|
|
||||||
|
Seit der Pruefung Z2 (2026-09-25). Vorher lief pruefen mit nur_lesen=False, und ein
|
||||||
|
Fehler in pruefen, der einen schreibenden Befehl schickte, waere nicht abgefangen
|
||||||
|
worden. Ueber supervisor/api geht nur GET und das Anlegen der Ingress-Sitzung
|
||||||
|
(ENDPUNKT_SITZUNG), das ConfigDatei auch lesend braucht. Jede andere Methode der
|
||||||
|
Verbindung -- http_post, http_delete -- ist hier gesperrt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ha, sitzung_endpunkt: str) -> None:
|
||||||
|
self._ha = ha
|
||||||
|
self._sitzung = sitzung_endpunkt
|
||||||
|
|
||||||
|
def _pruefe(self, typ: str, felder: dict) -> None:
|
||||||
|
if typ in LESEND:
|
||||||
|
return
|
||||||
|
if typ == "supervisor/api":
|
||||||
|
methode = str(felder.get("method", "")).lower()
|
||||||
|
if methode == "get":
|
||||||
|
return
|
||||||
|
if methode == "post" and felder.get("endpoint") == self._sitzung:
|
||||||
|
return
|
||||||
|
raise RuntimeError(f"pruefen ist lesend: {typ} {felder.get('method', '')} gesperrt".strip())
|
||||||
|
|
||||||
|
def befehl_ok(self, typ: str, **felder):
|
||||||
|
self._pruefe(typ, felder)
|
||||||
|
return self._ha.befehl_ok(typ, **felder)
|
||||||
|
|
||||||
|
def roh_sammeln(self, typ: str, dauer: float, **felder):
|
||||||
|
self._pruefe(typ, felder)
|
||||||
|
return self._ha.roh_sammeln(typ, dauer, **felder)
|
||||||
|
|
||||||
|
def __getattr__(self, name: str):
|
||||||
|
raise RuntimeError(f"pruefen ist lesend: {name} gesperrt")
|
||||||
|
|
||||||
|
|
||||||
|
def online(argumente) -> int:
|
||||||
|
ha_projekt, trading = Path(argumente.ha_projekt), Path(argumente.trading)
|
||||||
|
zugang = None
|
||||||
|
if argumente.aktion == "einrichten":
|
||||||
|
# Vor der Verbindung zu Home Assistant: Der WSL-Teil braucht bis zu WSL_TIMEOUT_S,
|
||||||
|
# und so lange soll keine Verbindung offen warten.
|
||||||
|
zugang = aus_wsl(argumente.tage)
|
||||||
|
print(
|
||||||
|
f"Aus WSL: Thema Laenge {len(zugang['thema'])}, Token Laenge "
|
||||||
|
f"{len(zugang['token'])}, {len(zugang['kalender'])} Handelstage"
|
||||||
|
)
|
||||||
|
sys.path.insert(0, str(ha_projekt / "src"))
|
||||||
|
import config_datei
|
||||||
|
import ha_verbindung as hv
|
||||||
|
|
||||||
|
# Die Pfadregel des HA-Projekts erlaubt nur themes/. Dieses Werkzeug braucht genau
|
||||||
|
# secrets.yaml und configuration.yaml und ersetzt die Regel dafuer zur Laufzeit; die
|
||||||
|
# Methoden von ConfigDatei schlagen pruefe_pfad erst beim Aufruf nach.
|
||||||
|
config_datei.pruefe_pfad = _nur_zwei_dateien
|
||||||
|
token = hv.lies_token()
|
||||||
|
print(f"Token gelesen (Laenge {len(token)}), Instanz aus dem HA-Projekt, Aktion {argumente.aktion}")
|
||||||
|
# nur_lesen=False auch fuer pruefen: get_services, get_states, trace/list und trace/get
|
||||||
|
# stehen nicht in der Leseliste des HA-Projekts (src/ha_verbindung.py, LESELISTE; dort
|
||||||
|
# nur render_template). Fuer pruefen sperrt statt dessen _NurLesend alles ausser genau
|
||||||
|
# diesen Befehlen (Pruefung Z2); die Dateien oeffnet es ueber ConfigDatei mit
|
||||||
|
# nur_lesen=True.
|
||||||
|
with hv.HaVerbindung(hv.BASIS_URL, token, nur_lesen=False) as verbindung:
|
||||||
|
ha = (
|
||||||
|
_NurLesend(verbindung, config_datei.ENDPUNKT_SITZUNG)
|
||||||
|
if argumente.aktion == "pruefen"
|
||||||
|
else verbindung
|
||||||
|
)
|
||||||
|
datei = config_datei.ConfigDatei(
|
||||||
|
hv.BASIS_URL,
|
||||||
|
lambda endpunkt, methode: ha.befehl_ok("supervisor/api", endpoint=endpunkt, method=methode),
|
||||||
|
nur_lesen=argumente.aktion == "pruefen",
|
||||||
|
)
|
||||||
|
with datei:
|
||||||
|
if argumente.aktion == "pruefen":
|
||||||
|
return pruefen(ha, datei)
|
||||||
|
if argumente.aktion == "einrichten":
|
||||||
|
return einrichten(ha, datei, trading, argumente.app_dienst, zugang)
|
||||||
|
if argumente.aktion == "probe":
|
||||||
|
return probe(ha, argumente.app_dienst)
|
||||||
|
return entfernen(ha, datei)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
zerleger = argparse.ArgumentParser(
|
||||||
|
description="Totmann der Trading Plattform in Home Assistant (Paket Z2).",
|
||||||
|
allow_abbrev=False,
|
||||||
|
)
|
||||||
|
zerleger.add_argument("aktion", choices=["pruefen", "trocken", "einrichten", "probe", "entfernen"])
|
||||||
|
zerleger.add_argument("ha_projekt", nargs="?")
|
||||||
|
zerleger.add_argument("trading", nargs="?")
|
||||||
|
zerleger.add_argument("--app-dienst", default=None)
|
||||||
|
zerleger.add_argument("--tage", type=int, default=400)
|
||||||
|
argumente = zerleger.parse_args(argv)
|
||||||
|
if argumente.aktion == "trocken":
|
||||||
|
return trocken()
|
||||||
|
if not argumente.ha_projekt or not argumente.trading:
|
||||||
|
zerleger.error("pruefen, einrichten, probe und entfernen brauchen beide Projektordner")
|
||||||
|
if argumente.aktion in ("einrichten", "probe") and (
|
||||||
|
argumente.app_dienst is None or not APP_DIENST_MUSTER.match(argumente.app_dienst)
|
||||||
|
):
|
||||||
|
zerleger.error("--app-dienst notify.<name> fehlt oder hat nicht diese Form")
|
||||||
|
try:
|
||||||
|
return online(argumente)
|
||||||
|
except (Voraussetzung, wsl_teil.Befund) as fehler:
|
||||||
|
# Diese Texte tragen keinen Wert aus der Geheimnisdatei, nur Feldnamen und Laengen.
|
||||||
|
print(f"ABBRUCH: {fehler}")
|
||||||
|
return 3
|
||||||
|
except Exception as ausnahme:
|
||||||
|
# Nur der Typ: Eine Ausnahme kann eine Adresse mit dem Thema oder einen Kopf mit
|
||||||
|
# einem Schluessel tragen.
|
||||||
|
print(f"ABBRUCH: {type(ausnahme).__name__}")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""WSL-Teil von ha_totmann.py: Thema, Token und Handelskalender, nur ueber die Pipe (Paket Z2).
|
||||||
|
|
||||||
|
Laeuft in WSL mit dem Python der Trading Plattform (~/venvs/trading; gebraucht wird nur die
|
||||||
|
Standardbibliothek). Die Geheimnisdatei der Plattform liegt nur hier. Dieses Skript liest
|
||||||
|
daraus [ntfy] thema und token und [alpaca] key_id und secret_key, fragt den Kalender von
|
||||||
|
Alpaca (GET /v2/calendar, lesend, ohne Proxy aus der Umgebung) und schreibt genau eine
|
||||||
|
JSON-Zeile auf stdout: thema, token, kalender.
|
||||||
|
|
||||||
|
Gestartet wird es nur von ha_totmann.py unter Windows, das stdout als Pipe liest und davon
|
||||||
|
nie etwas ausgibt. Von Hand gestartet stuende das Thema auf der Konsole -- deshalb gibt es
|
||||||
|
keinen Aufruf dafuer in LIESMICH.md. stderr traegt nur Befunde ohne Werte: Feldnamen und
|
||||||
|
Laengen.
|
||||||
|
|
||||||
|
ha_totmann.py importiert diese Datei auch unter Windows, fuer die Regeln (zugang,
|
||||||
|
pruefe_kalender, ausgabe) und fuer seine Aktion trocken; beim Import wird nichts gelesen.
|
||||||
|
|
||||||
|
Aufruf (nur durch ha_totmann.py): python ha_totmann_wsl.py [--geheimnisse PFAD] [--tage 400]
|
||||||
|
Exit: 0 fertig, 2 Ausnahme, 3 Voraussetzung fehlt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tomllib
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from datetime import date
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
GEHEIMNISSE = "/home/claude/.config/trading-plattform/secrets.toml"
|
||||||
|
ALPACA_KALENDER = "https://paper-api.alpaca.markets/v2/calendar"
|
||||||
|
ZEITLIMIT_S = 30
|
||||||
|
THEMA_MUSTER = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
|
||||||
|
TOKEN_MUSTER = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
||||||
|
KALENDER_FELDER = (
|
||||||
|
("date", re.compile(r"^\d{4}-\d{2}-\d{2}$")),
|
||||||
|
("open", re.compile(r"^\d{2}:\d{2}$")),
|
||||||
|
("close", re.compile(r"^\d{2}:\d{2}$")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Befund(Exception):
|
||||||
|
"""Eine Voraussetzung fehlt. Der Text nennt Feldnamen und Laengen, nie einen Wert."""
|
||||||
|
|
||||||
|
|
||||||
|
def zugang(geheim: dict) -> tuple[str, str]:
|
||||||
|
"""Thema und Token aus dem Abschnitt [ntfy]; der Token darf fehlen, das Thema nicht."""
|
||||||
|
ntfy = geheim.get("ntfy") or {}
|
||||||
|
if not isinstance(ntfy, dict):
|
||||||
|
raise Befund("[ntfy] ist kein Abschnitt")
|
||||||
|
thema = str(ntfy.get("thema") or "").strip()
|
||||||
|
token = str(ntfy.get("token") or "").strip()
|
||||||
|
if not THEMA_MUSTER.match(thema):
|
||||||
|
raise Befund(f"[ntfy] thema fehlt oder passt nicht zur Regel (Laenge {len(thema)})")
|
||||||
|
if token and not TOKEN_MUSTER.match(token):
|
||||||
|
raise Befund(f"[ntfy] token passt nicht zur Regel (Laenge {len(token)})")
|
||||||
|
return thema, token
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_kalender(daten: object) -> list[dict]:
|
||||||
|
"""Je Handelstag date, open und close; jede andere Form ist ein Befund."""
|
||||||
|
if not isinstance(daten, list):
|
||||||
|
raise Befund("Kalender: Antwort ist keine Liste")
|
||||||
|
tage = []
|
||||||
|
for eintrag in daten:
|
||||||
|
if not isinstance(eintrag, dict):
|
||||||
|
raise Befund("Kalender: ein Eintrag ist keine Tabelle")
|
||||||
|
for feld, muster in KALENDER_FELDER:
|
||||||
|
wert = eintrag.get(feld)
|
||||||
|
if not isinstance(wert, str) or not muster.match(wert):
|
||||||
|
raise Befund(f"Kalender: Feld {feld} nicht lesbar")
|
||||||
|
tage.append({feld: eintrag[feld] for feld, _muster in KALENDER_FELDER})
|
||||||
|
return tage
|
||||||
|
|
||||||
|
|
||||||
|
def ausgabe(thema: str, token: str, kalender: list[dict]) -> str:
|
||||||
|
"""Die eine Zeile fuer die Pipe. ASCII, damit kein Zeilentrenner darin stehen kann."""
|
||||||
|
return json.dumps(
|
||||||
|
{"thema": thema, "token": token, "kalender": kalender},
|
||||||
|
ensure_ascii=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def kalender_holen(alpaca: dict, tage: int) -> list[dict]:
|
||||||
|
"""GET /v2/calendar von heute bis heute plus tage, lesend, ohne Proxy aus der Umgebung."""
|
||||||
|
key_id = str(alpaca.get("key_id") or "")
|
||||||
|
secret_key = str(alpaca.get("secret_key") or "")
|
||||||
|
if not key_id or not secret_key:
|
||||||
|
raise Befund("[alpaca] key_id oder secret_key fehlt")
|
||||||
|
heute = date.today()
|
||||||
|
parameter = urllib.parse.urlencode(
|
||||||
|
{"start": heute.isoformat(), "end": (heute + timedelta(days=tage)).isoformat()}
|
||||||
|
)
|
||||||
|
anfrage = urllib.request.Request(
|
||||||
|
f"{ALPACA_KALENDER}?{parameter}",
|
||||||
|
headers={"APCA-API-KEY-ID": key_id, "APCA-API-SECRET-KEY": secret_key},
|
||||||
|
)
|
||||||
|
oeffner = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||||
|
with oeffner.open(anfrage, timeout=ZEITLIMIT_S) as antwort:
|
||||||
|
return pruefe_kalender(json.loads(antwort.read().decode("utf-8")))
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None) -> int:
|
||||||
|
zerleger = argparse.ArgumentParser(
|
||||||
|
description="WSL-Teil von ha_totmann.py; nur ueber die Pipe benutzen.",
|
||||||
|
allow_abbrev=False,
|
||||||
|
)
|
||||||
|
zerleger.add_argument("--geheimnisse", default=GEHEIMNISSE)
|
||||||
|
zerleger.add_argument("--tage", type=int, default=400)
|
||||||
|
argumente = zerleger.parse_args(argv)
|
||||||
|
try:
|
||||||
|
pfad = Path(argumente.geheimnisse)
|
||||||
|
if not pfad.is_file():
|
||||||
|
raise Befund("die Geheimnisdatei liegt nicht am erwarteten Ort")
|
||||||
|
geheim = tomllib.loads(pfad.read_text(encoding="utf-8"))
|
||||||
|
thema, token = zugang(geheim)
|
||||||
|
alpaca = geheim.get("alpaca") or {}
|
||||||
|
if not isinstance(alpaca, dict):
|
||||||
|
raise Befund("[alpaca] ist kein Abschnitt")
|
||||||
|
kalender = kalender_holen(alpaca, argumente.tage)
|
||||||
|
except Befund as befund:
|
||||||
|
print(f"BEFUND {befund}", file=sys.stderr)
|
||||||
|
return 3
|
||||||
|
except Exception as ausnahme:
|
||||||
|
# Nur der Typ: Eine Ausnahme kann eine Adresse oder einen Kopf mit Schluessel tragen.
|
||||||
|
print(f"ABBRUCH: {type(ausnahme).__name__}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
print(ausgabe(thema, token, kalender))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Geheimnis-Scan vor dem Push, mit Regressionsnachweis. Aufruf: bash scan_push.sh <von> <bis> [<bis> ...]
|
||||||
|
R="/mnt/d/Claude/Code Projekte/active - Trading Plattform"
|
||||||
|
W="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PY=/home/claude/venvs/trading/bin/python
|
||||||
|
von="$1"; shift
|
||||||
|
echo "Verlauf: $(git -C "$R" log --oneline "$von".."$1" | wc -l) Commits in $von..$1"
|
||||||
|
echo "== Probe (muss Treffer melden):"
|
||||||
|
SCAN_PROBE="${SCAN_PROBE_TEXT:-Nachholtage melden}" "$PY" "$W/geheimnis_scan.py" "$R" "$von" "$@" 2>&1 | tail -3
|
||||||
|
echo "== Scan:"
|
||||||
|
"$PY" "$W/geheimnis_scan.py" "$R" "$von" "$@" 2>&1 | tail -6
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
type: resource
|
type: resource
|
||||||
status: pending-review
|
status: reference
|
||||||
title: Codeworkflow
|
title: Codeworkflow
|
||||||
summary: Arbeitsweise für Code-Projekte — Standardschleife, Planaufteilung je Task, Subagenten und ihre Modellstufen und Effort, Gegenprüfung, Lauf mit echten Daten, Aufgabenmanagement.
|
summary: Arbeitsweise für Code-Projekte — Standardschleife, Planaufteilung je Task, Subagenten und ihre Modellstufen und Effort, Gegenprüfung, Lauf mit echten Daten, Aufgabenmanagement.
|
||||||
tags: [regelwerk, codeworkflow, code-projekte]
|
tags: [regelwerk, codeworkflow, code-projekte]
|
||||||
created: 2026-08-03
|
created: 2026-08-03
|
||||||
updated: 2026-09-24
|
updated: 2026-09-25
|
||||||
---
|
---
|
||||||
|
|
||||||
# Codeworkflow
|
# Codeworkflow
|
||||||
|
|||||||
Reference in new issue
Block a user