This commit introduces a detailed analysis of the Delphi Reactive Stream Pipeline architecture, contrasting it with the current Rust implementation. It outlines the core concepts of Delphi's stateless streams, `TStreamSignal`, `TRootStream`, `TPipeSource`, `TScalarSeries`, and `TPipeStream`, emphasizing their roles in event propagation, state management, and synchronization. The document also details the integration of the `pipe` statement within the Delphi AST and compiler pipeline, covering the parser, AST structure, type checker, and evaluator. Finally, it proposes a "Dual Series Architecture" for Rust, differentiating between script-local `RecordSeries` (for accumulators) and reactive `SharedSeries` (for the pipeline), and outlines a concrete implementation plan.
12 KiB
Architektur-Analyse: Delphi Reactive Stream Pipeline
Die Delphi-Implementierung der Streaming- und Pipeline-Architektur (insbesondere in Myc.Data.Stream.pas) unterscheidet sich fundamental von der aktuellen Rust-Implementierung der RecordSeries. Während die Rust-Version auf einem zentralen, zustandsbehafteten Ringspeicher ("Struct of Arrays") aufbaut, implementiert Delphi ein hochgradig entkoppeltes, reaktives Event-System.
Hier ist eine detaillierte Aufschlüsselung der Kernkomponenten und Konzepte in Delphi:
1. Trennung von Zustand und Event-Propagation (Stateless Streams)
In Delphi ist der Stream selbst (IStream / TCustomDataStream) zustandslos (stateless). Er speichert keine Historie.
- Ein Stream hat lediglich eine Definition (
IScalarRecordDefinition), die das Schema der Daten beschreibt. - Er verwaltet eine Liste von Abonnenten (
TSignalProc). - Wenn neue Daten eintreffen, ruft der Stream die Abonnenten über
Emit()auf und reicht dasTStreamSignaldurch.
2. Das Datenpaket: TStreamSignal
Das TStreamSignal ist das "Blut" der Pipeline. Es handelt sich um ein leichtgewichtiges Datenpaket, das durch den Graphen fließt.
- CycleID: Ein integraler Bestandteil für die Synchronisation (siehe Barrieren). Jeder Tick/Datensatz erhält eine eindeutige ID.
- Data: Ein Pointer (
TScalar.PValue) auf die flachen Skalarwerte des Records. Der Stream baut keine komplexen Objekte auf, sondern schickt nur Referenzen auf den Speicherbereich herum.
3. Die Taktung: TRootStream
Der TRootStream ist der Eintrittspunkt für Marktdaten (z.B. Ticks).
- Er generiert die globale
CycleID. - Beim Aufruf von
Push(RowData)inkrementiert er dieCycleIDund ruftEmit()auf. Dieser Root-Stream ist der Taktgeber (Clock) für alle nachgelagerten Berechnungen.
4. Zustand und Historie: TPipeSource & TScalarSeries
Da der Stream selbst keine Historie speichert, muss jemand anders diese Aufgabe übernehmen. Das ist die Rolle von TPipeSource.
- Rolle: Ein Observer, der sich an einen
IStreamankoppelt. - Filterung: Eine
TPipeSourceabonniert nicht den gesamten Record, sondern konzentriert sich immer auf einzelnes Feld (z.B. den Preis). - Historie: Die geparsten Werte dieses einen Feldes werden lokal in einer 1-dimensionalen Serie (
TScalarSeries) akkumuliert. - Shared Source: Delphi optimiert den Speicherbedarf über eine
TSharedSource. Wenn 10 verschiedene Indikatoren (Pipes) auf das Feld "Close" eines Root-Streams lauschen, wird intern nur eine einzigeTScalarSeriesaufgebaut und zwischen ihnen geteilt.
(Anmerkung: Die in Delphi existierende Klasse TScalarRecordSeries ist nicht das Rückgrat dieses reaktiven Systems, sondern existiert primär als Container oder für spezielle Evaluator-Zugriffe.)
5. Synchronisation & Transformation: TPipeStream
Dies ist das Herzstück für komplexe Berechnungen (z.B. Indikatoren, die auf Preis und Volumen basieren).
Ein TPipeStream ist gleichzeitig Observer und wieder ein Stream.
- Barriere-Synchronisation (
CheckBarrierAndFire): EinTPipeStreamhat mehrere Inputs (Quellen). Da Signale in einem komplexen Graphen asynchron eintreffen können, wartet derTPipeStream, bis alle seine Quellen Signale mit derselbenCycleIDempfangen haben. - Warm-up / Lookback: Er feuert erst, wenn genügend Historie (
FRequiredLookback) aufgebaut wurde. - Transformation (
TPipeLambda): Sobald die Barriere durchbrochen ist, wird die Transformations-Funktion ausgeführt. Diese liest die synchronisierten 1D-Serien und generiert einen neuen skalaren Output. - Emission: Der generierte Output wird wieder als neues Signal (
Emit()) mit der aktuellenCycleIDan nachgelagerte Streams verschickt.
6. AST & Compiler-Integration des pipe-Statements
Der Aufruf und die Definition von Pipes sind tief in die Delphi-AST-Pipeline integriert. Der Weg vom Quellcode zur Ausführung verläuft in folgenden Schritten:
a) Parser (Myc.Ast.Script.pas)
Die Grammatik für Pipes sieht im Skript wie folgt aus: (pipe [inputs] lambda).
Die Methode ParsePipe erwartet exakt zwei Argumente:
- Einen Vektor (
akTuple), der die Inputs spezifiziert. - Einen Lambda-Ausdruck (
akLambdaExpression), der die Berechnungslogik enthält. Daraus wird im AST einTPipeNode(Kind =akPipe) erzeugt.
b) AST-Struktur (Myc.Ast.Nodes.pas)
Der TPipeNode speichert die Komponenten strukturiert ab:
FInputs: EinITupleNode, das wiederum Tupel enthält, die die Ziel-Streams und die gewünschten Felder (Selektoren) referenzieren (z.B.[my_ticks [:price :volume]]).FTransformation: EinILambdaExpressionNode, das auf die Inputs angewandt wird.
c) TypeChecker (Myc.Ast.Compiler.TypeChecker.pas)
Der TypeChecker analysiert den TPipeNode detailliert (VisitPipe):
- Er verifiziert die Struktur der
Inputs(muss ein Array von Streams und deren Keyword-Selektoren sein). - Er validiert, dass die referenzierten Felder (z.B.
:price) in derRecordDefinitiondes jeweiligen Streams auch tatsächlich existieren. - Er passt die Signatur (die Parameter) des Lambdas automatisch an die statischen Typen der ausgewählten Stream-Felder an.
- Er leitet den statischen Rückgabetyp ab. Je nach Lambda-Rückgabe ist das Ergebnis entweder wieder eine
stRecordSeries(die Pipe generiert einen neuen Stream) oderstVoid(ein reiner Endpunkt ohne Ausgabe).
d) Evaluator (Myc.Ast.Evaluator.pas)
Zur Laufzeit (VisitPipe) wird die Pipe materialisiert:
- Die Inputs werden über den aktuellen Scope ("Environment") als
IStream-Instanzen aufgelöst. - Die Feld-Selektoren werden als Konfiguration (
TPipeConfig) aufgebaut. - Der Lambda-Ausdruck wird zu einer ausführbaren Funktion (
TDataValue.TFunc) evaluiert. - Es wird ein neuer
TPipeStreaminstanziiert, der die Source-Streams und die Lambda-Funktion alsTPipeLambda-Adapter übergeben bekommt. - Der
TPipeStreammeldet sich bei den Quell-Streams als Observer (Subscriber) an und das reaktive Netzwerk ist bereit.
Fazit & Zielarchitektur für Rust: Die Duale Serien-Architektur
Die ursprüngliche Annahme, die aktuelle Rust-Architektur (RecordSeries) sei grundlegend fehlerhaft und müsse abgelöst werden, greift zu kurz. Stattdessen haben wir es mit zwei völlig unterschiedlichen Konzepten und Lebenszyklen zu tun, die im Skript zwar unter dem gemeinsamen Begriff "Serie" zusammengefasst werden, intern in der Rust-Implementierung jedoch strikt getrennt sein müssen:
1. Die Skript-lokale Serie (Die aktuelle RecordSeries / SoA)
- Zweck: Dient als lokaler, zustandsbehafteter Puffer oder Akkumulator. Ein typischer Anwendungsfall ist die Definition innerhalb einer Indikator-Berechnung (z.B. ein lokaler Ringpuffer für einen SMA).
- Verhalten: Vom Skript-Autor explizit deklariert und mutiert (read/write). Die Serie existiert nur im lokalen Scope der Ausführung.
- Aktualisierung: Erfolgt manuell durch das Skript (z.B. mittels eines expliziten
push-Befehls in einer Schleife oder Berechnungslogik). - Bewertung: Die aktuelle Rust-Implementierung als Struct-of-Arrays (SoA) ist für diesen Anwendungsfall perfekt geeignet, da sie maximale Performance für schnelle, massenhafte lokale Operationen bietet. Sie bleibt als Datenstruktur erhalten, darf aber nicht in das reaktive Event-System integriert werden.
2. Die reaktive Pipeline & Publizierte Serien (SharedSeries)
- Zweck: Dies ist das systemgesteuerte, reaktive Rückgrat der Engine. Hier fließen kontinuierlich Marktdaten (Ticks) als getaktete Signale durch den von Pipes aufgespannten Berechnungsgraphen.
- Verhalten: Aus Sicht des Skripts sind diese Serien streng Read-Only. Das Skript kann diese Serien nur als Input konsumieren (z.B. für Lookbacks:
close[0]), aber keine Werte hineinpushen. - Speicher-Architektur: Die Datenhaltung erfolgt dedupliziert im Hintergrund. Wenn zehn verschiedene Indikator-Pipes denselben Input-Datenstrom (z.B. den Preis) konsumieren, teilen sie sich exakt denselben Speicherbereich im RAM. Dies ist das Rust-Äquivalent zur Delphi
TSharedSeries.
3. Der Klebstoff: Polymorphismus in der VM (Series Trait)
Damit der Skript-Autor keinen syntaktischen Unterschied zwischen der lokalen RecordSeries und einer reaktiven SharedSeries bemerkt (z.B. bei einem Lookback-Zugriff my_series[0]), müssen beide Konzepte hinter einer gemeinsamen Schnittstelle verborgen werden. Aus der Perspektive des AST und der VM (Evaluator) implementieren beide Typen dasselbe Verhalten für Lesezugriffe.
Konkreter Implementierungsplan für Rust
Um diese duale Architektur sauber und idiomatisch in Rust abzubilden, sind folgende konkrete Implementierungsschritte erforderlich:
Schritt 1: Die polymorphe Serien-Abstraktion
Series-Trait definieren: Einführung eines Traits (oder eines entsprechenden Enums in der VM-Darstellung wieBoundSeries), das die gemeinsamen Leseoperationen definiert. Zwingend erforderlich ist hier die Index-Operation für Lookbacks (z.B.fn get_lookback(&self, index: usize) -> ScalarValue).- Anpassung der VM: Der TypeChecker und Evaluator arbeiten zukünftig gegen diese Abstraktion, nicht mehr direkt gegen die konkrete
RecordSeries, wenn es um Lesezugriffe geht.
Schritt 2: Implementierung der SharedSeries
- Struktur: Eine neue Datenstruktur
SharedSeries, die das polymorpheSeries-Trait implementiert. - Shared State: Intern hält sie eine Referenz (
Rc<RefCell<...>>oder ähnlich, unter Einhaltung der Vorgabe, dass innerhalb des Root-Scopes alles Single-Threaded abläuft) auf den deduplizierten Datenpuffer der Pipeline. - Read-Only Enforcement: Implementierungsseitig werden alle Mutationsversuche (wie
pushoderclear) mit einem Panic oder Runtime-Error blockiert.
Schritt 3: Das reaktive Event-Routing (Stateless Streams)
- Publisher (
Stream-Trait): Definition eines zustandslosen Traits für Komponenten, die Events aussenden können. - Signal & Taktung: Einführung des
Signal-Structs (analog zuTStreamSignal), welches die global generierteCycleIDsowie einen Verweis auf die zu verarbeitenden Datensätze trägt. RootStream: Implementierung des Taktgebers. Er dient als Eintrittspunkt für neue externe Daten, generiert inkrementell dieCycleIDund ruft die nachgelagerten Observer auf.
Schritt 4: Die Pipe-Logik (PipeStream)
- Subscriber (
Observer-Trait): Definition für Komponenten, die sich an Streams anhängen (Subscribe). - Barrier-Synchronisation: Der
PipeStreamsammelt eintreffende Signale seiner deklarierten Quell-Streams. Er puffert diese und wartet, bis alle Signale mit exakt derselbenCycleIDeingetroffen sind (Barrier-Wait). - Ausführung: Sobald die Barriere durchbrochen ist (alle Inputs synchronisiert), wird das transformierende Lambda (das in der Pipe definierte Closure) ausgeführt.
- Kaskadierung: Das Ergebnis des Lambdas wird sofort wieder als neues Signal mit der aktuellen
CycleIDan weitere nachgelagerte Pipes (viaEmit()) weitergereicht.
Schritt 5: AST & Compiler-Integration des pipe-Statements
- Lexer/Parser: Erweiterung der Grammatik für das Konstrukt
(pipe [inputs] lambda)und Erzeugung eines entsprechendenakPipeAST-Knotens. - TypeChecker: Statische Analyse der Pipe. Überprüfung der Input-Selektoren gegen die
RecordDefinitionder referenzierten Streams. Automatische Inferenz der Typ-Signaturen, um die Parameter des übergebenen Lambdas zur Compilezeit korrekt an die statischen Datentypen der Stream-Felder zu binden. - Evaluator: Implementierung der Laufzeit-Logik (
VisitPipe), die das Lambda zu einer ausführbaren Funktion kompiliert, denPipeStreamals reaktiven Knoten instanziiert, im Graphen einhängt und mit den entsprechenden Quell-Streams verdrahtet.