"""Eigener Nachtlauf der Generalprobe: _generalprobe aus scripts/paper.py mit echten Kerzen, ohne Netz. Koordinator-Pruefcode, nicht Teil des Repos. Gerufen wird die echte Funktion _generalprobe -- dieselbe Verdrahtung wie um 9:00 Uhr --, umgebogen sind nur die Stellen, die sonst ins Netz oder in den Betrieb gingen: - REGISTER zeigt auf eine Sicherungskopie des echten Registers in einem Wegwerf-Ordner (das echte wird nur lesend geoeffnet), ARTEFAKTE auf den echten artifacts-Ordner (nur lesen: das Registermodell). - Statusdatei und Protokollordner liegen im Wegwerf-Ordner. - AlpacaKerzenquelle.from_secrets liefert die echten IEX-Kerzen des Tages aus einer JSON-Datei. - AlpacaBroker.from_secrets liefert ein mitschreibendes Konto, das glatt ist und bei jedem schreibenden Aufruf wirft -- damit steht im Bericht, was die Generalprobe am "echten" Broker ruft. - requests.Session.request schreibt mit und wirft: Jeder Netzversuch des Laufs faellt auf. - Der Melder ist ein echter Melder mit mitschreibendem Transport, in der Huelle GeneralprobeMelder. "Pascal" klickt aus einem zweiten Faden -- wie das Dashboard mit einem frischen Killswitch-Objekt aus dem Register, am mitschreibenden Konto: fortsetzen, spaeter anhalten und fortsetzen, glattstellen, freigeben. Aufruf (WSL): python generalprobe_echtlauf.py [tempo] """ import importlib.util import json import os import sqlite3 import sys import threading import time from dataclasses import replace from datetime import UTC from datetime import datetime from datetime import timedelta from pathlib import Path CODE = Path(sys.argv[1]).resolve() ECHT = Path(sys.argv[2]).resolve() KERZEN = Path(sys.argv[3]).resolve() WEG = Path(sys.argv[4]).resolve() TEMPO = float(sys.argv[5]) if len(sys.argv) > 5 else 1.0 os.chdir(CODE) sys.path.insert(0, str(CODE)) import requests # noqa: E402 from trading.broker.schnittstelle import Marktuhr # noqa: E402 from trading.killswitch.killswitch import Killswitch # noqa: E402 from trading.killswitch.killswitch import lade_killswitch_config # noqa: E402 from trading.paper.config import lade_paper_config # noqa: E402 from trading.paper.kerzen import HANDELSZONE # noqa: E402 from trading.paper.kerzen import Kerze # noqa: E402 from trading.paper.melder import Melder # noqa: E402 from trading.paper.melder import MelderConfig # noqa: E402 from trading.paper.status import lies_status # noqa: E402 from trading.registry.registry import Registry # noqa: E402 NETZVERSUCHE: list[str] = [] def _kein_netz(self, method, url, *args, **kwargs): NETZVERSUCHE.append(f"{method} {url.split('?')[0]}") raise requests.ConnectionError("Nachtlauf: kein Netz") requests.sessions.Session.request = _kein_netz class Antwort: status_code = 200 class Transport: def __init__(self): self.aufrufe = [] def __call__(self, url, *, data=None, headers=None, timeout=None, **rest): rumpf = data.decode("utf-8") if isinstance(data, bytes) else str(data) self.aufrufe.append( { "titel": headers.get("Title"), "prioritaet": headers.get("Priority"), "erste_zeile": rumpf.split("\n")[0], "echt": datetime.now(UTC).isoformat(timespec="seconds"), } ) return Antwort() class MitschreibendesKonto: """Steht fuer das echte Alpaca-Paper-Konto: glatt, keine Order, Boerse zu. Schreiben wirft.""" def __init__(self, name): self.name = name self.aufrufe = [] def positionen(self): self.aufrufe.append("positionen") return [] def offene_orders(self): self.aufrufe.append("offene_orders") return [] def marktuhr(self): self.aufrufe.append("marktuhr") jetzt = datetime.now(UTC) return Marktuhr(is_open=False, next_open=jetzt + timedelta(hours=8), next_close=jetzt + timedelta(hours=14)) def storniere_alle(self): self.aufrufe.append("storniere_alle") return [] def schliesse_alle(self): self.aufrufe.append("schliesse_alle") return [] def __getattr__(self, name): def wirft(*a, **k): self.aufrufe.append(f"VERBOTEN:{name}") raise AssertionError(f"{self.name}: unerwarteter Aufruf {name}") return wirft def lade_kerzen(pfad): roh = json.loads(Path(pfad).read_text(encoding="utf-8")) return [ Kerze(zeit=datetime.fromisoformat(e["zeit"]), open=e["open"], high=e["high"], low=e["low"], close=e["close"], volume=e["volume"]) for e in roh ] def main(): kerzen = lade_kerzen(KERZEN) tag = kerzen[0].zeit.astimezone(HANDELSZONE).date().isoformat() WEG.mkdir(parents=True, exist_ok=False) # Sicherungskopie des echten Registers; das echte wird nur lesend geoeffnet. quelle = sqlite3.connect(f"file:{ECHT / 'data' / 'registry.db'}?mode=ro", uri=True) ziel = sqlite3.connect(WEG / "registry.db") quelle.backup(ziel) quelle.close() # Die Spalte artefakt_pfad ist relativ zum Projektordner. In der Kopie wird sie absolut, damit der # Lauf aus dem Code-Baum heraus das Registermodell im echten artifacts-Ordner findet (nur lesen). for zeile in ziel.execute("SELECT agent, version, artefakt_pfad FROM versionen").fetchall(): pfad = Path(zeile[2].replace("\\", "/")) if not pfad.is_absolute(): ziel.execute("UPDATE versionen SET artefakt_pfad = ? WHERE agent = ? AND version = ?", (str(ECHT / pfad), zeile[0], zeile[1])) ziel.commit() ziel.close() spec = importlib.util.spec_from_file_location("paper_skript_nachtlauf", CODE / "scripts" / "paper.py") paper = importlib.util.module_from_spec(spec) spec.loader.exec_module(paper) paper.REGISTER = str(WEG / "registry.db") paper.ARTEFAKTE = str(ECHT / "artifacts") konto_der_probe = MitschreibendesKonto("Konto der Generalprobe") konto_des_dashboards = MitschreibendesKonto("Konto des Dashboards") class Kerzenquelle: @classmethod def from_secrets(cls, *a, **k): return cls() def kerzen_des_tages(self, instrument_id, ende): return list(kerzen) class BrokerFabrik: @classmethod def from_secrets(cls, *a, **k): return konto_der_probe paper.AlpacaKerzenquelle = Kerzenquelle paper.AlpacaBroker = BrokerFabrik config = replace( lade_paper_config("config/paper.toml"), status_datei=str(WEG / "paper" / "status.json"), protokoll_ordner=str(WEG / "protokolle"), ) ks_config = lade_killswitch_config("config/killswitch.toml") transport = Transport() melder = Melder( config=MelderConfig(aktiv=True, ntfy_url="https://ntfy.sh", thema="nachtlauf-geht-nie-ins-netz", zeitlimit_s=3.0, stille_nach_s=180.0), token="", transport=transport, ) beobachtet = {"max_alter_s": 0.0, "modi": set(), "marktzeiten": [], "phasen": [], "klicks": [], "rueckwaerts": 0, "position_nach_glatt": None, "fehler": []} halt = threading.Event() def dashboard_killswitch(): registry = Registry(db_pfad=WEG / "registry.db", artefakt_wurzel=ECHT / "artifacts") gespeichert = registry.killswitch_zustand() return Killswitch(broker=konto_des_dashboards, registry=registry, config=ks_config, zustand=gespeichert.zustand if gespeichert is not None else "angehalten") def klick(was, marktzeit, position): ks = dashboard_killswitch() vorher = ks.zustand if was == "glattstellen": ergebnis = ks.glattstellen("pascal", "Nachtlauf", ks_config.bestaetigungswort) else: ergebnis = getattr(ks, was)("pascal", "Nachtlauf") beobachtet["klicks"].append( f"{marktzeit.astimezone(HANDELSZONE):%H:%M} {was}: {vorher} -> {ergebnis.zustand}, " f"erfolgreich={ergebnis.erfolgreich}, gespielte Position vorher {position}" ) def pascal(): plan = [("fortsetzen", (9, 31)), ("anhalten", (10, 30)), ("fortsetzen", (10, 36)), ("glattstellen", (12, 0)), ("freigeben", (12, 10))] letzte = None glatt_um = None while not halt.is_set(): status = lies_status(config.status_datei) if isinstance(status, dict): try: zeit = datetime.fromisoformat(status["zeit"]) markt = datetime.fromisoformat(status["marktzeit"]) except (KeyError, ValueError, TypeError) as ausnahme: beobachtet["fehler"].append(repr(ausnahme)) time.sleep(0.2) continue if status.get("phase") != "beendet": alter = (datetime.now(UTC) - zeit).total_seconds() beobachtet["max_alter_s"] = max(beobachtet["max_alter_s"], alter) beobachtet["modi"].add(status.get("modus")) if not beobachtet["phasen"] or beobachtet["phasen"][-1] != status.get("phase"): beobachtet["phasen"].append(status.get("phase")) if letzte is not None and markt < letzte: beobachtet["rueckwaerts"] += 1 if letzte is None or markt != letzte: beobachtet["marktzeiten"].append(markt) letzte = markt ort = markt.astimezone(HANDELSZONE) if plan and (ort.hour, ort.minute) >= plan[0][1]: was, _ = plan.pop(0) try: klick(was, markt, status.get("position")) except Exception as ausnahme: beobachtet["klicks"].append(f"{ort:%H:%M} {was}: AUSNAHME {ausnahme!r}") if was == "glattstellen": glatt_um = markt if glatt_um is not None and beobachtet["position_nach_glatt"] is None \ and markt >= glatt_um + timedelta(minutes=2): beobachtet["position_nach_glatt"] = status.get("position") time.sleep(0.2) faden = threading.Thread(target=pascal, name="pascal", daemon=True) faden.start() beginn = time.monotonic() code = paper._generalprobe(config, tag, TEMPO, melder) dauer = time.monotonic() - beginn time.sleep(0.5) halt.set() faden.join(timeout=5) print("=" * 70) print(f"Tag {tag}, {len(kerzen)} Kerzen, Tempo {TEMPO:g} s je Marktminute, echte Dauer {dauer:.0f} s, Exit {code}") schluss = lies_status(config.status_datei) print(f"Statusdatei am Ende: modus={schluss.get('modus')}, phase={schluss.get('phase')}, " f"position={schluss.get('position')}, schluessel={sorted(schluss)}") print(f"Modi gesehen: {sorted(str(m) for m in beobachtet['modi'])}; Phasen: {beobachtet['phasen']}") zeiten = beobachtet["marktzeiten"] print(f"Marktzeit: {len(zeiten)} verschiedene Werte, von {zeiten[0].astimezone(HANDELSZONE):%Y-%m-%d %H:%M} " f"bis {zeiten[-1].astimezone(HANDELSZONE):%Y-%m-%d %H:%M}, Ruecksprung {beobachtet['rueckwaerts']}") print(f"Groesstes Alter des Herzschlags gegen die echte Uhr: {beobachtet['max_alter_s']:.1f} s") print("Klicks:") for zeile in beobachtet["klicks"]: print(" " + zeile) print(f"Gespielte Position zwei Marktminuten nach dem Glattstellen: {beobachtet['position_nach_glatt']}") print(f"Lesefehler im Beobachter: {beobachtet['fehler'][:3]}") protokolle = sorted((WEG / "protokolle").glob("*.jsonl")) zeilen = [] for pfad in protokolle: with pfad.open(encoding="utf-8") as datei: zeilen += [json.loads(zeile) for zeile in datei if zeile.strip()] takte = [z for z in zeilen if z.get("art") == "takt"] arten = {} for z in zeilen: schluessel = z.get("art") or z.get("was") or "?" arten[schluessel] = arten.get(schluessel, 0) + 1 print(f"Protokoll: {[p.name for p in protokolle]}, {len(zeilen)} Zeilen, Arten {arten}") orders = [z for z in zeilen if z.get("art") == "order" or z.get("was") == "order"] print(f"Takte {len(takte)}, Orderzeilen {len(orders)}") if takte: print(f"Erster Takt: {json.dumps(takte[0], ensure_ascii=False)[:400]}") print(f"Letzter Takt: {json.dumps(takte[-1], ensure_ascii=False)[:400]}") print(f"Meldungen ({len(transport.aufrufe)}):") for m in transport.aufrufe: print(f" {m['echt']} [{m['prioritaet']}] {m['titel']} | {m['erste_zeile']}") print(f"Aufrufe am Konto der Generalprobe: {konto_der_probe.aufrufe}") print(f"Aufrufe am Konto des Dashboards: {konto_des_dashboards.aufrufe}") print(f"Netzversuche ueber requests: {NETZVERSUCHE}") tagesstart = list(WEG.rglob("tagesstart.json")) print(f"tagesstart.json im Wegwerf-Ordner: {tagesstart}") registry = Registry(db_pfad=WEG / "registry.db", artefakt_wurzel=ECHT / "artifacts") ereignisse = registry.killswitch_ereignisse() frisch = [e for e in ereignisse if getattr(e, "grund", "") and "Nachtlauf" in e.grund or getattr(e, "ausloeser", "") in ("neustart", "paper-runner")] print(f"Killswitch-Ereignisse im Wegwerf-Register: {len(ereignisse)} gesamt; die letzten acht:") for e in ereignisse[-8:] if len(ereignisse) >= 8 else ereignisse: print(f" {getattr(e, 'zeit', '?')} {e.stufe} / {e.ausloeser} / {e.grund[:60]} / erfolgreich={e.erfolgreich}") print(f"Killswitch am Ende: {registry.killswitch_zustand().zustand}") befunde = [] if code != 0: befunde.append(f"Exit {code} statt 0") if beobachtet["modi"] != {"generalprobe"}: befunde.append(f"Modi {beobachtet['modi']}") if any(not str(m["titel"]).startswith("Generalprobe: ") for m in transport.aufrufe): befunde.append("eine Meldung ohne Vorsatz") if not transport.aufrufe: befunde.append("keine Meldung") if NETZVERSUCHE: befunde.append("Netzversuch") if sorted(konto_der_probe.aufrufe) != ["marktuhr", "offene_orders", "positionen"]: befunde.append("Konto der Generalprobe: andere Aufrufe als die drei lesenden") if any(a.startswith("VERBOTEN") for a in konto_des_dashboards.aufrufe): befunde.append("Konto des Dashboards: verbotener Aufruf") if beobachtet["position_nach_glatt"] != 0: befunde.append(f"Position nach dem Glattstellen {beobachtet['position_nach_glatt']}") if beobachtet["max_alter_s"] > 5.0: befunde.append(f"Herzschlag bis {beobachtet['max_alter_s']:.1f} s alt") if beobachtet["rueckwaerts"]: befunde.append("Marktzeit lief rueckwaerts") if tagesstart: befunde.append("tagesstart.json entstanden") if schluss.get("position") != 0: befunde.append(f"Position am Ende {schluss.get('position')}") print(f"{len(befunde)} Befund(e): {befunde}") return 2 if befunde else 0 if __name__ == "__main__": sys.exit(main())