Files
MycLib/KI/Aura-Discussion.txt
T
Michael Schimmel 284fb95985 AST
2025-08-27 15:16:13 +02:00

895 lines
43 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Delphi Coding
Benutzerdefiniertes Gem
Gedankengang anzeigen
Ja, absolut. Das von dir vorgestellte Typsystem ist eine ausgezeichnete Grundlage für einen Blockly-Automaten. Es ist flexibel, stark typisiert und durch die Trennung von Typdefinition (IDataType) und Wert (IDataValue) sehr gut für diesen Zweck geeignet.
Analyse der Eignung
Die Stärken deines Entwurfs für dieses Anwendungszenario sind:
Entkopplung von Typ und Wert: Blockly trennt ebenfalls die Form eines Blocks (seine Anschlüsse und deren Typen) von dem Wert, den er zur Laufzeit produziert. Dein IDataType entspricht perfekt der Typdefinition eines Anschlusses, während IDataValue den zur Laufzeit durchgereichten Wert repräsentiert.
Starke Typisierung und Überprüfung: Die Kind-Eigenschaft (TDataKind) und die spezifischen IData...Type-Interfaces ermöglichen eine robuste Typprüfung. Wenn in Blockly ein Block in einen anderen eingesteckt wird, kann dein Automat im Hintergrund prüfen, ob der IDataType des Ausgangsblocks mit dem erwarteten IDataType des Eingangsanschlusses kompatibel ist. Z.B. (Ausgang.DataType.Kind in [dkOrdinal, dkFloat]).
Unterstützung komplexer Datenstrukturen: Die meisten einfachen Blockly-Implementierungen beschränken sich auf primitive Typen. Dein System unterstützt von Haus aus komplexe und strukturierte Daten wie Record, Array, Tuple und Vector. Dies ermöglicht die Erstellung sehr mächtiger und domänenspezifischer Blöcke (z.B. "Erstelle Datensatz", "Füge Element zu Array hinzu", "Lese Feld 'Name' aus Datensatz").
Funktionale Typen (IDataMethodType): Das ist ein besonders mächtiges Feature. Du könntest damit in Blockly Blöcke für Prozeduren, Funktionen oder Callbacks realisieren. Ein Block könnte eine "Funktion" ausgeben, die ein anderer Block als Parameter entgegennimmt und ausführt.
Interface-Helper (TDataType / TValue): Die Record-Helper machen die Implementierung der eigentlichen Block-Logik (der Code, der ausgeführt wird, wenn ein Block "tickt") sehr sauber und sicher. Der Code des Automaten arbeitet mit den handlichen TDataType-Records anstatt direkt mit den Interfaces.
Konkretes Mapping von Blockly zu deinem Typsystem
Blockly-Konzept Mapping auf dein Typsystem Beispiel
Anschluss-Typ IDataType (gekapselt in TDataType) Ein "Number"-Anschluss erwartet TDataType mit Kind = dkOrdinal.
Ausgabewert eines Blocks IDataValue (gekapselt in TDataType.TValue) Ein Zahlen-Block gibt ein TDataType.TOrdinal.TValue zurück.
Zahlen-Literal Block TDataType.Ordinal.CreateValue(123) Erzeugt einen Wert vom Typ Ordinal.
Text-Literal Block TDataType.Text.CreateValue('Hello') Erzeugt einen Wert vom Typ Text.
Listen/Array-Block TDataType.ArrayOf(someType).CreateValue([...]) Erzeugt ein Array von Werten.
Objekt/Record-Block TDataType.RecordOf(fields).CreateValue([...]) Erzeugt einen strukturierten Datensatz.
In Google Sheets exportieren
Was noch zu bedenken wäre
Dein Typsystem ist die Datenrepräsentation. Der eigentliche Automat (Interpreter/Executor) muss noch implementiert werden. Dieser würde typischerweise als Visitor über den von Blockly generierten AST (Abstract Syntax Tree) laufen:
Ausführungslogik: Jeder Block-Typ im Interpreter würde die entsprechenden CreateValue-Methoden deines Typsystems aufrufen, um Werte zu erzeugen oder zu transformieren.
Variablen-Handling: Der Automat benötigt einen "Scope" oder eine "Symboltabelle", die Variablennamen auf IDataValue-Instanzen abbildet.
Fehlerbehandlung: Du könntest einen speziellen IDataValue für Fehlerzustände einführen oder auf Exceptions im Automaten setzen, wenn z.B. eine Typumwandlung fehlschlägt.
Serialisierung: Die IDataValue-Instanzen, die den Zustand des Automaten repräsentieren, müssen möglicherweise serialisiert werden. Dafür bräuchtest du eine Logik, die einen Wert wieder in eine speicherbare Form (z.B. JSON) umwandelt.
Fazit: Du hast hier eine extrem solide und gut durchdachte Basis geschaffen, die weit über das hinausgeht, was für einen einfachen Blockly-Automaten nötig wäre, aber gleichzeitig die Implementierung eines sehr leistungsfähigen und typsicheren Systems ermöglicht.
Erläutere, wie in diesem Zusammenhang ein AST (Abstract Syntax Tree) ausehen müsste.
D
Delphi Coding
Benutzerdefiniertes Gem
Gedankengang anzeigen
Absolut. Ein AST (Abstract Syntax Tree) ist die entscheidende Brücke zwischen der visuellen Darstellung von Blockly und deiner Ausführungslogik in Delphi. Er übersetzt die räumliche Anordnung der Blöcke in eine hierarchische, programmatische Struktur, die ein Interpreter verarbeiten kann.
In diesem Kontext würde der AST nicht direkt Delphi-Code repräsentieren, sondern die Aktionen und Werte innerhalb deines Myc.Data.Types-Systems.
Grundlegende Struktur des AST
Jeder Knoten im AST repräsentiert einen Blockly-Block. Man unterscheidet fundamental zwischen zwei Arten von Knoten:
Expression Nodes (Ausdrucksknoten): Repräsentieren Blöcke, die einen Wert zurückgeben (z.B. Literale, Variablen, mathematische Operationen). Das Ergebnis der Auswertung eines solchen Knotens ist immer ein IDataValue.
Statement Nodes (Anweisungsknoten): Repräsentieren Blöcke, die eine Aktion ausführen (z.B. eine Zuweisung, eine Schleife, ein if-Konstrukt). Sie geben selbst keinen Wert zurück, sondern verändern den Zustand des Automaten.
Man könnte dies in Delphi mit einer Basis-Schnittstelle und spezialisierten Implementierungen abbilden:
Code-Snippet
// Base interface for all AST nodes
IAstNode = interface
// The visitor pattern is the classic way to process an AST.
function Accept(Visitor: IAstVisitor): IDataValue;
end;
// Base for nodes that represent a value
IExpressionNode = interface(IAstNode)
end;
// Base for nodes that represent an action
IStatementNode = interface(IAstNode)
end;
Konkrete AST-Knoten (Beispiele)
Hier sind einige Beispiele, wie konkrete Blockly-Blöcke als AST-Knoten aussehen könnten:
Programm-Start:
TProgramNode = class(TInterfacedObject, IStatementNode)
Properties: Statements: TArray<IStatementNode>
Repräsentiert: Die gesamte Kette von Anweisungsblöcken, die von oben nach unten ausgeführt werden.
Literal-Wert (Zahl, Text, ...):
TLiteralNode = class(TInterfacedObject, IExpressionNode)
Properties: Value: IDataValue
Repräsentiert: Einen Block, der einen konstanten Wert darstellt. Dies ist der "direkteste Draht" zu deinem Typsystem.
Variablen-Zuweisung:
TAssignmentNode = class(TInterfacedObject, IStatementNode)
Properties: VariableName: string, Expression: IExpressionNode
Repräsentiert: Den "setze Variable X auf..."-Block.
Variablen-Zugriff:
TVariableAccessNode = class(TInterfacedObject, IExpressionNode)
Properties: VariableName: string
Repräsentiert: Den Block, der den Wert einer Variable liest.
Binäre Operation (z.B. Addition):
TBinaryOpNode = class(TInterfacedObject, IExpressionNode)
Properties: Operator: TMyOperatorEnum, Left: IExpressionNode, Right: IExpressionNode
Repräsentiert: Einen Block mit zwei Eingängen, z.B. [ ] + [ ].
If-Anweisung:
TIfStatementNode = class(TInterfacedObject, IStatementNode)
Properties: Condition: IExpressionNode, ThenBranch: IStatementNode, ElseBranch: IStatementNode (kann nil sein)
Repräsentiert: Den if-then-else-Block. Die ThenBranch und ElseBranch sind typischerweise selbst wieder TProgramNode (oder eine TStatementListNode), die eine Sequenz von Anweisungen enthalten.
Beispiel: Blockly zu AST
Stell dir folgende Blöcke vor:
setze [x] auf (10 + [y])
Der resultierende AST würde so aussehen:
TAssignmentNode (Statement)
VariableName: 'x'
Expression: TBinaryOpNode (Expression)
Operator: opAdd
Left: TLiteralNode (Expression)
Value: IDataOrdinalValue mit dem Wert 10
Right: TVariableAccessNode (Expression)
VariableName: 'y'
Verarbeitung des AST (Der Interpreter)
Der AST an sich ist nur eine Datenstruktur. Um ihn auszuführen, implementiert man einen Interpreter, meistens mithilfe des Visitor-Patterns.
IAstVisitor Interface:
Definiert für jeden konkreten Knotentyp eine Visit...-Methode.
Code-Snippet
IAstVisitor = interface
function VisitProgram(Node: TProgramNode): IDataValue;
function VisitAssignment(Node: TAssignmentNode): IDataValue;
function VisitLiteral(Node: TLiteralNode): IDataValue;
function VisitBinaryOp(Node: TBinaryOpNode): IDataValue;
// ... etc.
end;
TInterpreter Klasse:
Implementiert IAstVisitor. Diese Klasse hält den Zustand des Automaten (z.B. eine TDictionary<string, IDataValue> für die Variablen).
VisitLiteral gibt einfach Node.Value zurück.
VisitBinaryOp ruft rekursiv Node.Left.Accept(Self) und Node.Right.Accept(Self) auf, um die IDataValue-Operanden zu erhalten. Dann führt er die Operation aus (z.B. Addition der Value-Properties der IDataOrdinalValue-Interfaces) und erzeugt ein neues IDataOrdinalValue als Ergebnis.
VisitAssignment wertet Node.Expression.Accept(Self) aus und speichert den resultierenden IDataValue in seinem Variablen-Dictionary.
Der Prozess ist also:
Blockly XML/JSON -> Parser -> AST -> Interpreter (Visitor) -> Ergebnis (IDataValue)
Dein Typsystem ist die Grundlage für die TLiteralNode-Blätter des Baumes und die IDataValue-Objekte, die während der Interpretation durch den Baum nach oben und unten gereicht werden.
Wie passt IDataMethodType in dieses Schema?
D
Delphi Coding
Benutzerdefiniertes Gem
Gedankengang anzeigen
Ausgezeichnete Frage. IDataMethodType und der zugehörige IDataMethodValue sind der Schlüssel, um dein System von einem reinen Daten-Manipulator zu einer vollwertigen Programmierumgebung mit Prozeduren und Funktionen als "First-Class-Citizens" zu erweitern.
Sie passen sich in das AST-Schema ein, indem sie die Konzepte Funktionsdefinition und Funktionsaufruf abbilden.
1. Die Funktionsdefinition (Ein neuer Statement-Knoten)
In Blockly würde dies einem "erstelle Prozedur/Funktion"-Block entsprechen. Dieser Block definiert eine Aktion, speichert sie unter einem Namen, aber führt sie nicht sofort aus. Daher ist die Definition ein Statement.
Neuer AST-Knoten: TFunctionDefinitionNode
Typ: IStatementNode
Properties:
FunctionName: string
Parameters: TArray<TParameterDef> (wobei TParameterDef Name und IDataType enthält)
Body: IStatementNode (Der Code innerhalb der Funktion, meist ein TProgramNode)
ResultType: IDataType (kann TDataType.Void sein für Prozeduren)
Verarbeitung im Interpreter (VisitFunctionDefinition):
Hier passiert die Magie, die direkt auf IDataMethodValue zugreift:
Der Visitor betritt diesen Knoten. Er führt den Body nicht aus.
Er erstellt den passenden IDataMethodType aus den Parameters und dem ResultType. Zum Beispiel: TDataType.MethodOf(ArgType, ResultType).
Er erzeugt dynamisch eine TDataMethodProc (eine anonyme Methode), die den Body des AST-Knotens und den aktuellen Scope des Interpreters einfängt (dies nennt man eine Closure).
Diese TDataMethodProc wird die eigentliche Implementierung der Funktion sein. Wenn sie aufgerufen wird, wird sie:
Einen neuen, untergeordneten Scope für die Funktionsparameter erstellen.
Die übergebenen IDataValue-Argumente in diesen Scope legen.
Den Body-AST-Knoten mit dem Visitor ausführen (Body.Accept(Self)).
Den Rückgabewert (ein IDataValue) zurückgeben.
Der Visitor ruft MethodType.CreateValue(ErzeugteTDataMethodProc) auf, um einen IDataMethodValue zu erzeugen.
Dieser IDataMethodValue wird in der Symboltabelle (Variablen-Dictionary) des Interpreters unter FunctionName gespeichert.
Das Ergebnis ist, dass nach diesem Statement eine Variable existiert, deren Wert eine ausführbare Funktion ist.
2. Der Funktionsaufruf (Ein neuer Expression-Knoten)
Dies entspricht dem "rufe Prozedur/Funktion auf"-Block in Blockly. Der Aufruf einer Funktion, die einen Wert zurückgibt, ist eine Expression.
Neuer AST-Knoten: TFunctionCallNode
Typ: IExpressionNode (oder IStatementNode, wenn der Rückgabetyp Void ist)
Properties:
FunctionName: string
Arguments: TArray<IExpressionNode> (Die an die Funktion übergebenen Werte)
Verarbeitung im Interpreter (VisitFunctionCall):
Der Visitor sucht in seiner Symboltabelle nach dem FunctionName. Er erwartet, einen IDataMethodValue zu finden. Wenn nicht -> Fehler.
Er wertet jeden IExpressionNode in Arguments rekursiv aus (arg.Accept(Self)), um eine Liste von IDataValue-Objekten zu erhalten.
Er ruft die im IDataMethodValue gespeicherte TDataMethodProc mit den ermittelten IDataValue-Argumenten auf.
Der von der TDataMethodProc zurückgegebene IDataValue ist das Ergebnis des VisitFunctionCall.
Beispiel-Szenario
Blockly:
// Definition
to double with [value]
return (value * 2)
// Aufruf
set [myResult] to [double with (10)]
AST:
TFunctionDefinitionNode
FunctionName: 'double'
Parameters: [Name: 'value', Type: TDataType.Ordinal]
ResultType: TDataType.Ordinal
Body: TReturnStatementNode mit einem TBinaryOpNode (*) drin.
TAssignmentNode
VariableName: 'myResult'
Expression: TFunctionCallNode
FunctionName: 'double'
Arguments: [TLiteralNode mit Wert 10]
Interpreter-Ablauf:
VisitFunctionDefinition('double'): Der Interpreter erzeugt eine Closure, verpackt sie in einen IDataMethodValue und speichert sie unter dem Namen 'double' in seinem globalen Scope.
VisitAssignment('myResult'): Er muss den Ausdruck auf der rechten Seite auswerten.
VisitFunctionCall('double'):
a. Er findet die 'double'-Funktion (IDataMethodValue) im Scope.
b. Er wertet das Argument aus: VisitLiteral(10) gibt einen IDataOrdinalValue(10) zurück.
c. Er ruft die Closure mit diesem IDataOrdinalValue auf.
d. Die Closure führt den Body aus: 10 * 2, was einen neuen IDataOrdinalValue(20) erzeugt.
e. Dieser Wert wird vom Aufruf zurückgegeben.
Der IDataOrdinalValue(20) wird in der Symboltabelle unter 'myResult' gespeichert.
Fazit: IDataMethodType ist die Typsignatur für die Verbindungsstellen der Funktionsblöcke in Blockly. IDataMethodValue ist das Laufzeitobjekt, das eine Referenz auf den ausführbaren Code (den Body-Teil des AST) enthält und im Scope des Interpreters wie eine normale Variable gespeichert wird. Dieses Muster integriert sich perfekt in das AST/Visitor-Schema.
Dann könnte man auch eine Faktory deklareieren, die ihrerseits Funktionen basierend auf Parametern erzeugt?
D
Delphi Coding
Benutzerdefiniertes Gem
Gedankengang anzeigen
Ja, exakt. Das ist die logische und sehr mächtige Konsequenz aus dem bisherigen Entwurf.
Was du beschreibst, ist das Konzept von Higher-Order Functions: Funktionen, die andere Funktionen als Parameter entgegennehmen oder wie in deinem Fall als Ergebnis zurückgeben. Dein System kann das abbilden, weil IDataMethodValue einfach nur ein weiterer Wert (IDataValue) ist.
Eine Funktion, die eine andere Funktion erzeugt, ist im Grunde eine Function Factory.
So würde es im etablierten Schema funktionieren:
Nehmen wir ein klassisches Beispiel: Eine Factory createAdder, die eine Zahl x entgegennimmt und eine neue Funktion zurückgibt. Diese neue Funktion addiert bei jedem Aufruf die ursprüngliche Zahl x zu ihrem eigenen Argument.
1. Die Darstellung in Blockly:
Du würdest eine Funktion createAdder mit einem Parameter amountToAdd definieren.
Innerhalb des do-Blocks dieser Funktion gäbe es nur einen einzigen Block: einen return-Block.
In den return-Block würdest du einen anonymen Funktionsblock (Lambda) einfügen. Dieser Block definiert einen eigenen Parameter, z.B. inputValue, und sein Rumpf wäre die Berechnung inputValue + amountToAdd.
2. Die Repräsentation im AST:
Der AST für die Factory createAdder würde so aussehen:
TFunctionDefinitionNode
FunctionName: 'createAdder'
Parameters: [Name: 'amountToAdd', Type: TDataType.Ordinal]
ResultType: TDataType.MethodOf(TDataType.Ordinal, TDataType.Ordinal) (Das ist der entscheidende Punkt: der Rückgabetyp ist selbst ein Funktionstyp!)
Body: TReturnStatementNode
Expression: TLambdaNode (die erzeugte, anonyme Funktion)
Parameters: [Name: 'inputValue', Type: TDataType.Ordinal]
ResultType: TDataType.Ordinal
Body: TBinaryOpNode (Operator +)
Left: TVariableAccessNode ('inputValue')
Right: TVariableAccessNode ('amountToAdd')
3. Die Verarbeitung im Interpreter (Die Magie der Closures):
Hier zeigt sich die Stärke deines Designs:
Aufruf der Factory: Dein Skript führt aus: set [add5] to [createAdder with (5)].
Der Interpreter ruft die Funktion createAdder auf. Es wird ein Scope erzeugt, in dem amountToAdd den IDataOrdinalValue(5) hat.
Der Interpreter wertet den Rumpf aus und stößt auf den TLambdaNode.
Wie zuvor beschrieben, erzeugt der Visitor für diesen TLambdaNode eine Closure.
Das Wichtigste: Diese Closure friert ihren Erstellungskontext ein. Sie "erinnert" sich daran, dass in ihrem übergeordneten Scope amountToAdd = 5 war.
Die Factory createAdder gibt den IDataMethodValue dieser neuen Closure zurück.
Dieser IDataMethodValue wird in der Variable add5 gespeichert. Die Funktion createAdder ist nun beendet, ihr Scope ist weg, aber die Closure lebt weiter.
Aufruf der erzeugten Funktion: Dein Skript führt aus: set [result] to [add5 with (3)].
Der Interpreter ruft den in add5 gespeicherten IDataMethodValue auf.
Die Closure wird mit dem Argument inputValue = IDataOrdinalValue(3) aktiviert. Es wird ein neuer Scope für diesen Aufruf erzeugt.
Der Interpreter führt den Körper der Closure aus: inputValue + amountToAdd.
Er findet inputValue (Wert 3) in seinem direkten Scope.
Er findet amountToAdd nicht in seinem direkten Scope. Er schaut daher in den "eingefrorenen" Scope, den er bei seiner Erzeugung mitbekommen hat, und findet dort den Wert 5.
Er berechnet 3 + 5 und gibt IDataOrdinalValue(8) zurück.
Fazit:
Ja, das ist nicht nur möglich, sondern es ist eine natürliche und elegante Eigenschaft des Systems, das wir besprochen haben. Es erfordert keine speziellen Tricks, sondern ergibt sich direkt aus der Fähigkeit, Funktionen als erstklassige Werte zu behandeln und dem korrekten Management von Scopes durch Closures im Interpreter. Das macht den Automaten extrem ausdrucksstark.
Ich möchte damit Handellssysteme erzeugen. Diese bestehen auf einer Strategie, die für einen bestimmten Zeitraum auf Kurse angewdandt wird. Das Ergebnis ist eine Equity-Curve. Diese Strategie muss durch eine Parametrisierbare Factory erzeugt werden können, damit sie an einzelne backtests oder sogar für Walk-Forward-Analyse benutzt werden kann. Und sie soll am Ende auch Live traden können. Ist das Denkbar?
D
Delphi Coding
Benutzerdefiniertes Gem
Gedankengang anzeigen
Ja, das ist absolut denkbar. Tatsächlich ist das ein exzellenter und sehr anspruchsvoller Anwendungsfall für die Architektur, die wir entworfen haben. Das System aus Blockly, AST und dem typsicheren Interpreter bildet den perfekten Kern einer solchen Handelsplattform.
Lassen Sie uns Ihre Anforderungen auf die besprochene Architektur abbilden:
1. Die parametrisierbare Strategie-Factory
Dies ist exakt der Anwendungsfall für eine Higher-Order Function, den wir zuletzt besprochen haben.
Blockly-Implementierung: Sie erstellen eine Funktion in Blockly, z.B. CreateMACrossoverStrategy. Diese Funktion hat Parameter wie FastMAPeriod, SlowMAPeriod, RiskPerTrade, etc.
Rückgabewert: Diese Factory-Funktion gibt eine andere Funktion zurück (einen IDataMethodValue). Nennen wir diese die "Strategie-Funktion".
Strategie-Funktion: Diese zurückgegebene Funktion hat eine feste Signatur, die vom "Harness" (dem Backtester oder Live-Trader) erwartet wird, z.B. function(CurrentBar: IDataRecordValue, Portfolio: IPortfolioApi): TSignal. Sie hat die Parameter der Factory (z.B. FastMAPeriod = 10, SlowMAPeriod = 50) in ihrer Closure "eingebacken".
2. Anwendung auf Kurse (Backtesting & Live-Trading)
Der Interpreter allein reicht hier nicht. Sie benötigen ein umgebendes "Harness", das die Strategie ausführt. Dieses Harness wäre für die verschiedenen Modi (Backtest, Live) austauschbar.
Datenmodellierung: Ihre Myc.Data.Types Unit ist hierfür ideal.
Ein einzelner Kursbalken (OHLC) wäre ein TDataType.TRecord.
Die gesamte Kurshistorie wäre ein TDataType.TArray dieser Records.
Das "Harness": Dies ist eine Delphi-Anwendung, die:
Die Blockly-Definition lädt und den AST erzeugt.
Den Interpreter startet, um die Strategie-Factory aufzurufen und eine konkrete Strategie-Instanz (IDataMethodValue) mit den gewünschten Parametern zu erzeugen.
Eine Schleife startet, die über die historischen (Backtest) oder live eintreffenden (Live-Handel) Kursdaten iteriert.
In jedem Schleifendurchlauf wird die Strategie-Funktion (IDataMethodValue) aufgerufen und ihr der aktuelle Kursbalken übergeben.
Broker-Abstraktion: Die Strategie muss Orders platzieren können. Dies geschieht über eine API, die dem Interpreter zur Verfügung gestellt wird.
Sie definieren ein Delphi-Interface IBrokerAPI mit Methoden wie BuyMarket(Amount), SellLimit(Price, Amount), GetCurrentPositionSize(), etc.
Im Backtest: Sie übergeben eine TSimulatedBroker-Klasse, die IBrokerAPI implementiert. Diese Klasse führt ein simuliertes Portfolio, berechnet Slippage/Kommissionen und erzeugt die Equity-Curve.
Im Live-Handel: Sie übergeben eine TRealBroker-Klasse, die sich mit der API Ihres echten Brokers (z.B. Interactive Brokers, Binance) verbindet.
Die in Blockly erstellte Strategie bleibt exakt dieselbe. Nur die Implementierung der IBrokerAPI wird ausgetauscht.
3. Walk-Forward-Analyse
Die Architektur unterstützt dies perfekt. Eine Walk-Forward-Analyse ist lediglich eine übergeordnete Schleife, die das Backtesting-Harness steuert:
Optimierungsphase: Die übergeordnete Schleife ruft das Backtest-Harness wiederholt für einen "In-Sample"-Zeitraum auf. Bei jedem Durchlauf wird die Strategie-Factory mit anderen Parametern (FastMAPeriod, SlowMAPeriod, ...) aufgerufen, um die profitabelsten Einstellungen zu finden.
Validierungsphase: Die übergeordnete Schleife nimmt die besten Parameter aus der Optimierungsphase und ruft das Backtest-Harness einmalig für den nachfolgenden "Out-of-Sample"-Zeitraum auf, um die Performance auf unbekannten Daten zu messen.
Dieser Prozess wird für gleitende Zeitfenster wiederholt.
Mögliche Herausforderungen und Erweiterungen
Performance: Die Interpretation eines AST für jeden einzelnen Tausender von Kursbalken kann bei der Optimierung (Millionen von Durchläufen) zu einem Engpass führen.
Lösung: Kritische Indikatoren (Moving Average, RSI, etc.) werden nicht in Blockly implementiert, sondern als hochoptimierte Delphi-Funktionen. Diese werden dem Interpreter als "native Funktionen" zur Verfügung gestellt, die von speziellen TNativeFunctionCallNode im AST aufgerufen werden können.
Komplexität des Zustands: Eine Strategie hat einen Zustand (z.B. "bin ich gerade in einem Trade?"). Dieser Zustand wird über Variablen im Scope des Interpreters gehalten.
Daten-Feeds: Sie benötigen eine robuste Anbindung an historische Datenquellen und Live-Feeds. Dies ist Teil des Harness, nicht des Interpreters.
Fazit
Ja, es ist absolut denkbar und ein sehr passender Anwendungsfall. Das entworfene System bietet eine saubere Trennung zwischen:
Strategie-Logik (Blockly/AST): Definiert von Analysten oder Tradern ohne tiefgehende Programmierkenntnisse.
Kern-Infrastruktur (Delphi Interpreter & Harness): Die hochperformante und robuste Basis, die von Entwicklern erstellt wird (Indikatoren, Broker-Anbindung, Daten-Feeds).
Sie schaffen damit eine domänenspezifische Sprache (DSL) für Handelsstrategien, die visuell, flexibel und durch die Delphi-Basis extrem leistungsfähig ist.
Der Harness ist der Knackpunkt. Wäre es denkbar auch das Backtesting und die Analyse über Blockly steuern zu können. Z.B. über spezialisierte Blöcke "Backtest" und "WFA"?
D
Delphi Coding
Benutzerdefiniertes Gem
Gedankengang anzeigen
Ja, das ist nicht nur denkbar, sondern ein konzeptionell sehr eleganter und mächtiger Schritt. Damit heben Sie das gesamte System von einer reinen "Strategie-Entwicklungsumgebung" auf die Ebene einer vollwertigen, visuellen "Quantitative-Research-Umgebung".
Der Knackpunkt, den Sie identifiziert haben, wird dabei elegant gelöst: Das "Harness" wird zu einer API, die innerhalb der Blockly-Umgebung über spezialisierte Blöcke zugänglich gemacht wird.
Die Architektur mit zwei Ebenen
Sie würden effektiv mit zwei Ebenen von Skripten arbeiten:
Ebene 1: Das Strategie-Skript (wie bisher besprochen)
Dies ist eine Funktion (erzeugt von einer Factory), die die Handelslogik enthält.
Sie nimmt typischerweise Kursdaten und den Portfolio-Zustand als Input und gibt Handelssignale als Output.
Diese Ebene weiß nichts von Backtesting oder Live-Handel. Sie ist agnostisch.
Ebene 2: Das Kontroll- oder Analyse-Skript
Dies ist ein übergeordnetes Blockly-Skript, das den gesamten Forschungs- oder Handelsprozess steuert.
Es verwendet die Strategie-Funktion (den IDataMethodValue) von Ebene 1 als Parameter für die neuen, spezialisierten Harness-Blöcke.
Spezialisierte Blöcke für das "Harness"
Hier sind die Blöcke, die Sie erwähnt haben, und wie sie sich einfügen würden:
[Load Price Data]-Block (Expression)
Inputs: Symbol, Zeitrahmen, Start-Datum, End-Datum.
Output: Ein IDataArrayValue mit den Kursdaten (ein Array von Records).
Implementierung: Ein nativer Delphi-Aufruf, der Daten aus einer Datenbank oder Datei lädt.
[Backtest]-Block (Expression oder Statement)
Inputs:
Price Data: Der IDataArrayValue vom Load-Block.
Strategy: Ein IDataMethodValue! Hier stecken Sie die von Ihrer Factory erzeugte Strategie-Instanz hinein.
Initial Capital: Ein IDataOrdinalValue oder IDataDecimalValue.
Commission: Ein IDataFloatValue.
Output: Ein IDataRecordValue, das alle Ergebnisse enthält: EquityCurve (ein Array), TradeList (ein Array), Statistics (ein weiteres Record).
Implementierung: Ein mächtiger, nativer Delphi-Aufruf. Der Interpreter ruft eine einzelne Delphi-Funktion ExecuteBacktest auf und übergibt ihr die IDataValue-Parameter. Diese Funktion führt dann die hochoptimierte Backtesting-Schleife aus und ruft bei jedem Takt die übergebene Strategie-Funktion (Strategy.Value(...)) auf.
[Walk Forward Analysis]-Block (Expression oder Statement)
Inputs:
Price Data.
Strategy Factory: Hier wird die Factory selbst (IDataMethodValue) übergeben, nicht eine Instanz. Der WFA-Block muss ja selbst Instanzen mit verschiedenen Parametern erzeugen können.
Parameter Ranges: Ein IDataRecordValue, das die zu optimierenden Parameter definiert (z.B. {FastMA: {start:10, end:100, step:5}}).
Weitere Parameter wie In-Sample-Länge, Out-of-Sample-Länge.
Output: Ein IDataArrayValue, das die Ergebnisse jeder einzelnen Walk-Forward-Periode enthält.
Implementierung: Ein weiterer komplexer, nativer Delphi-Aufruf, der die gesamte WFA-Logik kapselt.
Beispiel eines Analyse-Skripts in Blockly
Stellen Sie sich vor, Sie würden folgendes Skript in Blockly zusammenklicken:
// Lade die nötigen Daten
set [eurusd_h1] to [Load Price Data symbol:"EURUSD", timeframe:"H1", from:"2020-01-01", to:"2025-01-01"]
// Hole einen Verweis auf die Strategie-Factory
// (angenommen, die Factory "createMACrossover" wurde in einem anderen Tab definiert)
set [myStrategyFactory] to [get function handle of "createMACrossover"]
// Erzeuge eine spezifische Instanz der Strategie für den Test
set [strategy_10_50] to [call function [myStrategyFactory] with FastMA:10, SlowMA:50]
// Führe den Backtest aus
set [testResult] to [Backtest data:[eurusd_h1], strategy:[strategy_10_50], capital:10000]
// Analysiere und visualisiere die Ergebnisse
set [stats] to [get field "Statistics" from [testResult]]
print("Sharpe Ratio: " + [get field "SharpeRatio" from [stats]])
[Plot Equity Curve from: [get field "EquityCurve" from [testResult]]]
Die Vorteile dieses Ansatzes
Maximale Flexibilität: Der Anwender kann nicht nur die Strategie, sondern den gesamten Forschungs-Prozess visuell gestalten und automatisieren. Komplexe Vergleiche (Strategie A vs. Strategie B auf denselben Daten) werden trivial.
Performance: Die rechenintensiven Operationen (Backtesting-Schleife, WFA-Optimierung) laufen als kompilierter, nativer Delphi-Code. Nur die übergeordnete Orchestrierung und die Strategie-Logik selbst werden interpretiert. Das ist der "Best of both worlds"-Ansatz.
Kapselung: Die Komplexität der Harness-Implementierung ist hinter einfachen Blöcken verborgen. Der Anwender muss nicht wissen, wie ein Backtest funktioniert, sondern nur, dass er ihn mit einer Strategie und Daten füttern muss.
Protokollierung und Reproduzierbarkeit: Das gesamte Analyse-Skript kann als einzelnes Blockly-XML gespeichert werden, was jeden Forschungsschritt perfekt dokumentiert und reproduzierbar macht.
Fazit: Ja, das ist der logische nächste Schritt, um ein extrem mächtiges und benutzerfreundliches Werkzeug zu schaffen. Die Harness-Funktionalität wird zu einer "nativen API" innerhalb Ihrer visuellen Programmierumgebung.
So in etwa habe ich mir das vorgestellt. Zu den Preisdaten: die können nicht als Array zur Verfügung gestellt werden. Schließlich handelt es sich (im Live-Modus) um Echtzeitdaten, also Ticks oder 1M Bars. Wie würde man dieses Problem lösen?
D
Delphi Coding
Benutzerdefiniertes Gem
Gedankengang anzeigen
Das ist ein exzellenter und entscheidender Punkt. Sie haben vollkommen recht, das batch-orientierte Array-Modell ist nur für Backtests geeignet. Für den Live-Handel benötigen wir ein ereignisgesteuertes (event-driven) Modell.
Die gute Nachricht ist, dass unsere Architektur dies mit einer eleganten Abstraktion lösen kann, die es sogar erlaubt, die Logik für Backtesting und Live-Handel nahezu identisch zu halten.
Die Lösung liegt darin, nicht die Daten selbst, sondern eine Abstraktion der Datenquelle (einen "Data Provider") zu übergeben und auf dessen Ereignisse zu reagieren.
1. Einführung des "Data Provider"-Konzepts
Wir führen einen neuen Typ in unserem System ein: den DataProvider. Dies ist im Hintergrund ein Delphi-Objekt, das eine Datenquelle repräsentiert, und in Blockly wird es durch einen speziellen IDataValue (z.B. IDataProviderValue) dargestellt. Es ist ein "Handle" zur Datenquelle.
Ein DataProvider hat eine Hauptaufgabe: Er emittiert ein OnNewData-Ereignis, sobald neue Daten (ein Tick oder ein neuer Balken) verfügbar sind.
2. Neue Blöcke für den Live-Modus
Um dies in Blockly zu steuern, benötigen wir zwei neue Arten von Blöcken im "Kontroll-Skript" (Ebene 2):
[Create Live Data Provider]-Block (Expression)
Inputs: Symbol, Zeitrahmen, Broker/Feed-API.
Output: Ein DataProvider-Handle.
Implementierung: Dieser native Delphi-Block erzeugt eine Instanz eines Live-Providers, der sich z.B. per WebSocket mit einer Börse verbindet. Er startet aber noch nicht den Datenfluss.
[On New Bar]-Block (Event Handler)
Dies ist der wichtigste Block. Es ist ein Ereignis-Handler, kein normaler sequenzieller Block.
Er hat einen Eingangs-Slot für ein DataProvider-Handle.
Er definiert eine lokale Variable (z.B. newBar), die bei jedem Ereignis den neuen Kursbalken (IDataRecordValue) enthält.
Er hat einen "do"-Bereich, in den die Logik eingefügt wird, die bei jedem eintreffenden Balken ausgeführt werden soll.
3. Der Arbeitsablauf im Live-Modus
Ihr Kontroll-Skript für den Live-Handel würde nun so aussehen:
// --- Initialisierungsphase ---
// Erzeuge eine Instanz der Strategie
set [myStrategy] to [call function [get function handle of "createMACrossover"] with FastMA:10, SlowMA:50]
// Erzeuge und konfiguriere den Live-Datenstrom
set [liveFeed] to [Create Live Data Provider symbol:"BTCUSDT", timeframe:"1m", broker:"Binance"]
// Erzeuge eine Instanz des echten Brokers
set [realBroker] to [Create Broker API for account:"MyLiveAccount"]
// --- Reaktive Phase (Ereignis-Schleife) ---
[On New Bar [currentBar] from [liveFeed] do
// Diese Logik wird bei jedem neuen 1-Minuten-Balken ausgeführt
// Rufe die eigentliche Strategie-Funktion auf
set [signal] to [call function [myStrategy] with bar:[currentBar], broker:[realBroker]]
// (Hier könnte noch zusätzliche Logik stehen, z.B. Logging)
]
// Starte den Datenstrom (dieser Block beendet sich sofort, die Events laufen im Hintergrund)
[Start Data Provider [liveFeed]]
4. Die Vereinheitlichung: Backtesting als simulierter Stream
Jetzt kommt der eleganteste Teil: Wir können das Backtesting so umgestalten, dass es denselben ereignisgesteuerten Mechanismus verwendet.
Der [Backtest]-Block, den wir zuvor besprochen haben, ist im Grunde ein Wrapper, der Folgendes tut:
Er lädt die historischen Daten in ein Array (wie gehabt).
Er erstellt einen SimulatedDataProvider.
Dieser simulierte Provider hat ebenfalls ein Start-Kommando. Wenn es aufgerufen wird, iteriert er intern durch das Array und feuert für jeden Balken exakt dasselbe OnNewData-Ereignis wie der Live-Provider.
Wenn das Array-Ende erreicht ist, feuert er ein OnFinished-Ereignis.
Der Backtest-Block führt die Logik aus, wartet auf das OnFinished-Ereignis und gibt dann das gesammelte Ergebnis zurück.
Der entscheidende Vorteil: Die Logik innerhalb des [On New Bar ... do ...]-Blocks muss nicht wissen, ob die Balken aus einem Live-Feed oder einem Simulator kommen. Sie kann für beide Szenarien identisch sein. Dies eliminiert einen der größten Risikofaktoren im algorithmischen Handel: Diskrepanzen zwischen Backtest- und Live-Implementierung.
Fazit:
Sie lösen das Problem, indem Sie von einem daten-zentrierten (Übergabe eines Arrays) zu einem quell-zentrierten (Übergabe eines Providers) Modell wechseln und eine ereignisgesteuerte Verarbeitung einführen. Das ist die Standard-Architektur für reaktive Systeme und passt perfekt zum Live-Handel.
Könnte man dem Provider das [On New Bar...] nicht auch einfach als closure-Parameter mitgeben?
D
Delphi Coding
Benutzerdefiniertes Gem
Gedankengang anzeigen
Ja, absolut. Das ist eine hervorragende Beobachtung und eine konzeptionell sehr saubere, funktionale Alternative zum deklarativen Event-Handler-Block. Anstatt auf ein globales Ereignis zu lauschen, würden Sie die Verarbeitungslogik direkt als Parameter übergeben.
Dieser Ansatz passt perfekt zum Higher-Order-Function-Paradigma, das wir bereits für die Strategie-Factories etabliert haben.
So würde der Ansatz mit dem Closure-Parameter aussehen:
Sie würden den [On New Bar]-Block durch einen "ausführenden" Block ersetzen, der die Logik als Argument entgegennimmt.
Neuer Block: [Subscribe to Provider] oder [Run Provider] (Statement)
Inputs:
Provider: Das DataProvider-Handle (wie gehabt).
onNewBar: Ein IDataMethodValue. Dies ist der entscheidende Input ein Slot, der eine Funktion erwartet. Die Signatur dieser Funktion wäre function(bar: IDataRecordValue).
onError (optional): Ein weiterer IDataMethodValue für die Fehlerbehandlung.
onFinished (optional): Ein IDataMethodValue, der aufgerufen wird, wenn der Stream endet (relevant für Backtests).
Implementierung: Dieser native Delphi-Block würde den Provider starten und die übergebene onNewBar-Closure als Callback für das interne OnNewData-Ereignis des Providers registrieren.
Beispiel-Skript im neuen Stil
Das Live-Trading-Skript würde sich leicht verändern. Die Logik, die vorher im "do"-Bereich des Event-Handlers stand, wird nun in einen anonymen Funktionsblock (Lambda) gepackt und direkt übergeben:
// --- Initialisierungsphase (bleibt gleich) ---
set [myStrategy] to [...]
set [liveFeed] to [...]
set [realBroker] to [...]
// --- Start der Verarbeitung mit Closure als Callback ---
[Subscribe to Provider [liveFeed] onNewBar: [
// Dies ist ein anonymer Funktionsblock (Lambda),
// der als IDataMethodValue in den Slot gereicht wird.
function with [currentBar] do
call function [myStrategy] with bar:[currentBar], broker:[realBroker]
end function
]]
Vergleich der beiden Ansätze
Beide Ansätze sind valide und führen zum Ziel. Es ist eine Design-Entscheidung, welche Metapher Sie in Ihrer visuellen Sprache bevorzugen.
Eigenschaft Ansatz 1: [On New Bar ...] Ansatz 2: Closure-Parameter
Metapher Abonnement / Event-Listener Callback / Higher-Order Function
Stil Deklarativ, ereignisgesteuert. Funktional, imperativ.
Vorteile - Visuell sehr intuitiv, ähnlich wie UI-Programmierung (button.onClick).<br>- Einfache Anbindung mehrerer, unabhängiger Handler an dieselbe Quelle. - Konzeptionell extrem sauber und in sich geschlossen.<br>- Expliziter Daten- und Kontrollfluss.<br>- Konsistent mit dem "Alles ist ein Wert"-Prinzip.
Nachteile - Der Kontrollfluss ist weniger explizit; die Verbindung zwischen Quelle und Handler ist implizit.<br>- Wirkt weniger wie ein einzelnes, zusammenhängendes Programm. - Visuell etwas "verschachtelter" durch den Lambda-Block.<br>- Anbindung mehrerer Handler erfordert eine manuelle Dispatcher-Funktion.
AST-Knoten TEventHandlerNode TFunctionCallNode (für "Subscribe"), der einen TLambdaNode als Argument nimmt.
In Google Sheets exportieren
Fazit
Ihr Vorschlag ist exzellent. Er ist aus Programmierersicht oft die "reinere" Lösung, da er Abhängigkeiten explizit macht. Für eine visuelle DSL, die vielleicht auch von Nicht-Programmierern genutzt werden soll, kann der [On New Bar]-Ansatz manchmal eingängiger sein.
Die Schönheit Ihrer Architektur ist, dass die zugrundeliegende Delphi-Implementierung des DataProvider (mit einem simplen TEvent OnNewData) problemlos beide Block-Varianten unterstützen kann. Sie könnten sogar beide Blöcke anbieten und dem Benutzer die Wahl lassen, welchen Stil er bevorzugt.
Schwierig. Ich möchte z.B. in der Lage sein, aus einem Feed mehrere Signale in verschiedenen Timeframes zu generieren und diese dann zu gewichten. Und es soll auch möglich sein, mehrere verschieden Feeds zu analysieren um einee Handelsentscheidung zu treffen.
D
Delphi Coding
Benutzerdefiniertes Gem
Gedankengang anzeigen
Das ist eine exzellente, praxisnahe Anforderung. Und hier zeigt sich, meiner Meinung nach, die klare Stärke des ereignisgesteuerten Ansatzes ([On New Bar ...]) gegenüber dem funktionalen Callback-Ansatz.
Ihr Vorschlag mit der Closure ist elegant für einen einzelnen Datenstrom. Aber für die parallele Verarbeitung mehrerer asynchroner Datenströme, deren Ergebnisse zu einem Zeitpunkt X zusammengeführt werden müssen, ist das Abonnement-Modell (Subscription) oft wesentlich klarer und flexibler.
Ihre Anforderung lässt sich mit folgenden Konzepten abbilden:
DataProvider-Ketten (Resampler): Ein Baustein, der einen DataProvider als Input nimmt und einen neuen DataProvider als Output liefert.
Parallele Event-Handler: Mehrere [On New Bar]-Blöcke, die unabhängig voneinander auf verschiedene Datenquellen lauschen.
Geteilter Zustand (Shared State): Globale Variablen im Kontroll-Skript, die die Signale der einzelnen Quellen speichern.
Aggregationslogik: Eine Prozedur, die immer dann aufgerufen wird, wenn sich einer der Inputs ändert, um eine finale Handelsentscheidung zu treffen.
Szenario 1: Mehrere Timeframes aus einem Feed
Ziel: Aus einem 1-Minuten-Feed Signale für 1M, 5M und 15M generieren und gewichten.
Lösung: Wir führen einen neuen "intelligenten" Provider-Block ein, den Resampler.
Neuer Block: [Aggregate Timeframe] (oder [Create Resampled Feed])
Input:
Source Provider: Der DataProvider der feineren Zeiteinheit (z.B. der 1M-Live-Feed).
New Timeframe: z.B. "5m", "15m", "1h".
Output: Ein neuer DataProvider-Handle, der nur dann ein OnNewData-Ereignis auslöst, wenn ein vollständiger Balken der neuen Zeiteinheit fertig ist.
Implementierung: Ein nativer Delphi-Wrapper, der die ankommenden 1M-Balken sammelt und zu 5M-Balken zusammenfügt.
Ihr Kontroll-Skript würde dann so aussehen:
// --- 1. Initialisierung der Datenquellen ---
set [live_1m_feed] to [Create Live Data Provider symbol:"BTCUSDT", timeframe:"1m"]
// Erzeuge die aggregierten Feeds aus der 1M-Quelle
set [feed_5m] to [Aggregate Timeframe from:[live_1m_feed] to:"5m"]
set [feed_15m] to [Aggregate Timeframe from:[live_1m_feed] to:"15m"]
// --- 2. Initialisierung des geteilten Zustands ---
set [signal_1m] to 0
set [signal_5m] to 0
set [signal_15m] to 0
// --- 3. Parallele Verarbeitung der Feeds (Event Handlers) ---
[On New Bar [bar] from [live_1m_feed] do
set [signal_1m] to [call function [calculateSignal_1m] with data:[bar]]
call function [MakeFinalDecision] // Prüfe bei jedem 1M-Tick neu
]
[On New Bar [bar] from [feed_5m] do
set [signal_5m] to [call function [calculateSignal_5m] with data:[bar]]
// Eine Neubewertung ist hier nicht zwingend nötig, da sie schon durch den 1M-Feed getriggert wird.
]
[On New Bar [bar] from [feed_15m] do
set [signal_15m] to [call function [calculateSignal_15m] with data:[bar]]
]
// --- 4. Aggregationslogik ---
to MakeFinalDecision do
// Diese Funktion liest den geteilten Zustand und trifft die Entscheidung
set [weighted_signal] to ([signal_1m] * 0.5) + ([signal_5m] * 0.3) + ([signal_15m] * 0.2)
if [weighted_signal] > 0.8 then
[Buy Market on broker:[myBroker] amount:1]
else if [weighted_signal] < -0.8 then
[Sell Market on broker:[myBroker] amount:1]
end if
end function
Szenario 2: Mehrere verschiedene Feeds
Ziel: Den BTC/USDT-Feed und zusätzlich einen S&P500-Feed zur Stimmungsanalyse nutzen.
Architektonisch ist dies exakt derselbe Anwendungsfall. Anstatt die Feeds durch Resampling zu erzeugen, erstellen Sie einfach zwei unabhängige Live Data Provider.
// --- 1. Initialisierung der Datenquellen ---
set [btc_feed] to [Create Live Data Provider symbol:"BTCUSDT", timeframe:"1h"]
set [sp500_feed] to [Create Live Data Provider symbol:"SPY", timeframe:"1h"]
// --- 2. Initialisierung des geteilten Zustands ---
set [btc_trend_signal] to 0
set [market_sentiment] to "NEUTRAL" // Kann Text, Zahl, etc. sein
// --- 3. Parallele Verarbeitung der Feeds ---
[On New Bar [bar] from [btc_feed] do
set [btc_trend_signal] to [call function [calculateBTCTrend] with data:[bar]]
call function [MakeFinalBTCDecision] // Nur hier wird eine Handelsentscheidung für BTC getroffen
]
[On New Bar [bar] from [sp500_feed] do
set [market_sentiment] to [call function [calculateSentiment] with data:[bar]]
// Hier wird nur der Zustand aktualisiert, keine Order platziert
]
// --- 4. Aggregationslogik ---
to MakeFinalBTCDecision do
// Diese Funktion berücksichtigt jetzt den Zustand beider Feeds
if ([btc_trend_signal] > 0.7) and ([market_sentiment] = "BULLISH") then
[Buy Market on broker:[myBroker] for symbol:"BTCUSDT" amount:1]
end if
end function
Fazit
Die ereignisgesteuerte Architektur mit parallelen On New Bar-Handlern ist für diese komplexen, realen Szenarien wie geschaffen. Sie ermöglicht:
Entkopplung: Jeder "Signal-Generator" ist eine eigenständige, isolierte Einheit.
Klarheit: Das visuelle Layout der parallelen Blöcke spiegelt die tatsächliche parallele Datenverarbeitung wider.
Flexibilität: Das Hinzufügen einer weiteren Datenquelle (z.B. Zinsdaten) bedeutet lediglich das Hinzufügen eines weiteren DataProvider- und On New Bar-Blockpaars und die Anpassung der finalen Entscheidungslogik.
Der Versuch, dies mit verschachtelten Callbacks (dem Closure-Ansatz) abzubilden, würde schnell zu einem unübersichtlichen und schwer wartbaren Code führen (bekannt als "Callback Hell" oder "Pyramid of Doom"). Hier ist das deklarative Abo-Modell klar überlegen.