From 3bccd90d973b34d4465e7a8cb43458f3b1a0afa6 Mon Sep 17 00:00:00 2001 From: Richard Date: Sat, 5 Sep 2026 12:59:44 +0200 Subject: [PATCH] Eigentumsregeln 3 und 4: gebundener Ausfuehrungszugang und eine Grenze mit Zaehnen Regel 3 verlangt, dass die Buchzuordnung serverseitig gesetzt wird und TradeSignal.SourceModule nicht vertrauenswuerdig ist. Bisher war nur der unbekannte Name abgefangen; ein Modul konnte weiterhin das Kuerzel eines ANDEREN registrierten Moduls eintragen und dessen Cap, dessen Konzentration und dessen GuV belasten. Jedes Modul bekommt jetzt einen an sein Buch gebundenen IModuleExecution-Zugang statt des gemeinsamen IExecutionService. Der Zugang ueberschreibt die Herkunft mit seinem eigenen Buch - was im Signal steht, spielt keine Rolle mehr. Der Zugang steht ausdruecklich NICHT im Container: was dort steht, kann jedes Modul aufloesen, auch den Zugang eines anderen. Der Host uebergibt stattdessen eine Fabrik beim Registrieren (neue Ueberladung von IModule.RegisterServices mit Vorgabe, damit ein nicht handelndes Modul nichts davon wissen muss), und das Modul reicht den erzeugten Zugang direkt an seine Strategie. Regel 4 - Module bekommen den Broker nicht per DI - war eingehalten, aber nicht erzwungen. Jetzt zwei Tests ueber alle vier Modul-Assemblies: der eine prueft per Reflection Konstruktorparameter, Felder und Eigenschaften auf IBrokerClient, IBrokerPortfolioReader und IExecutionService; der andere durchsucht die Modulquellen nach dem zweiten Weg, dem Griff in den Container. Beide Gegenproben mit einem absichtlichen Regelbruch gefahren: jeder Test schlaegt bei genau seinem Weg an. Getrennte Container je Modul waeren der harte Weg, aendern aber die Modularchitektur. Der Fall, um den es geht, ist ein NEUES Modul, das den Core versehentlich umgeht - und den faengt der Test beim Uebersetzen. 404 Tests gruen, auf Windows und auf der Test-VM. Behebt #474, damit ist auch #302 vollstaendig. Co-Authored-By: Claude Opus 5 --- src/IBKRTrader.Core/Modularity/IModule.cs | 14 ++ .../Trading/IModuleExecution.cs | 71 +++++++++++ src/IBKRTrader.Hosting/AppHostBuilder.cs | 6 +- .../CongressTradingModule.cs | 21 ++- .../Strategy/CongressTradingStrategy.cs | 10 +- .../Modules/CongressTradingStrategyTests.cs | 2 +- .../Modules/ModuleBoundaryTests.cs | 120 ++++++++++++++++++ 7 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 src/IBKRTrader.Core/Trading/IModuleExecution.cs create mode 100644 tests/IBKRTrader.Tests/Modules/ModuleBoundaryTests.cs diff --git a/src/IBKRTrader.Core/Modularity/IModule.cs b/src/IBKRTrader.Core/Modularity/IModule.cs index 19b7d23..2cc5153 100644 --- a/src/IBKRTrader.Core/Modularity/IModule.cs +++ b/src/IBKRTrader.Core/Modularity/IModule.cs @@ -24,6 +24,20 @@ public interface IModule /// void RegisterServices(IServiceCollection services, IConfiguration configuration); + /// + /// Wie oben, zusätzlich mit der Fabrik für den an das Buch des Moduls gebundenen + /// Ausführungszugang. Der Host ruft diese Fassung; die Vorgabe reicht an die einfache weiter, + /// damit ein Modul, das gar nicht handelt, nichts davon wissen muss. + /// + /// Ein handelndes Modul überschreibt sie und gibt den Zugang an seine Strategie weiter – + /// er gehört ausdrücklich nicht in den Container. Was dort steht, kann jedes Modul + /// auflösen, auch der Zugang eines anderen, und damit auf dessen Buch handeln + /// (Kapitalmodell §2 Regel 3). + /// + void RegisterServices(IServiceCollection services, IConfiguration configuration, + Trading.ModuleExecutionFactory execution) + => RegisterServices(services, configuration); + /// /// Registriert die UI-Ansichten des Moduls bei der Shell (Launcher). Die Shell öffnet jede Ansicht /// auf Wunsch in einem eigenen Fenster; Module liefern eigene Forms, die Shell bleibt modul-agnostisch. diff --git a/src/IBKRTrader.Core/Trading/IModuleExecution.cs b/src/IBKRTrader.Core/Trading/IModuleExecution.cs new file mode 100644 index 0000000..bdb03cb --- /dev/null +++ b/src/IBKRTrader.Core/Trading/IModuleExecution.cs @@ -0,0 +1,71 @@ +using IBKRTrader.Core.Capital; + +namespace IBKRTrader.Core.Trading; + +/// +/// Der Ausführungszugang eines einzelnen Moduls – fest an dessen Buch gebunden. +/// +/// Warum nicht einfach . Dort trägt das Modul seine +/// Herkunft selbst im Signal ein (TradeSignal.SourceModule), und ein Feld, das der +/// Absender füllt, ist kein Ausweis. Ein Modul könnte das Kürzel eines anderen eintragen; die +/// Position liefe dann auf dessen Cap, dessen Konzentration und dessen GuV. Kein heutiges Modul +/// tut das – aber das Kapitalmodell verlangt in §2 Regel 3 ausdrücklich, dass die Zuordnung +/// serverseitig gesetzt wird, und eine Regel, auf deren Einhaltung man hofft, ist keine. +/// +/// Der Host erzeugt je Modul genau eine Instanz und übergibt sie beim Registrieren. Sie +/// steht nicht im gemeinsamen Container: Ein Modul kann den Zugang eines anderen deshalb +/// nicht auflösen. +/// +public interface IModuleExecution +{ + /// Das Buch, auf das jede Ausführung dieses Zugangs läuft. + BookId Book { get; } + + /// + /// Übergibt ein Signal an die Ausführungs-Pipeline des Cores. Die Herkunft wird dabei + /// überschrieben – was im Signal steht, spielt keine Rolle. + /// + Task ExecuteAsync(TradeSignal signal, CancellationToken ct = default); +} + +/// +/// Bindet ein Modul an sein Buch: setzt die Herkunft und reicht weiter. +/// +/// Bewusst kein Filter und keine zweite Prüfung – der Zugang soll nichts anderes tun, als +/// das eine Feld zu setzen, das ein Modul nicht selbst bestimmen darf. Alles Weitere entscheidet +/// die Pipeline, für jedes Modul gleich. +/// +public sealed class ModuleExecution : IModuleExecution +{ + private readonly IExecutionService _inner; + + public ModuleExecution(BookId book, IExecutionService inner) + { + Book = book; + _inner = inner; + } + + public BookId Book { get; } + + public Task ExecuteAsync(TradeSignal signal, CancellationToken ct = default) => + _inner.ExecuteAsync(signal with { SourceModule = Book.Value }, ct); +} + +/// +/// Erzeugt den gebundenen Ausführungszugang eines Moduls – erst dann, wenn der Container steht. +/// +/// Beim Registrieren gibt es noch keinen ; das Modul bekommt +/// deshalb die Fabrik und ruft sie in seiner eigenen Dienst-Registrierung auf. Der Zugang landet +/// so in der Strategie des Moduls statt im Container – und ist damit für kein anderes +/// Modul auflösbar. +/// +public sealed class ModuleExecutionFactory +{ + public ModuleExecutionFactory(BookId book) => Book = book; + + /// Das Buch, an das jeder erzeugte Zugang gebunden ist. + public BookId Book { get; } + + public IModuleExecution Create(IServiceProvider services) => + new ModuleExecution(Book, (IExecutionService)services.GetService(typeof(IExecutionService))!); +} diff --git a/src/IBKRTrader.Hosting/AppHostBuilder.cs b/src/IBKRTrader.Hosting/AppHostBuilder.cs index 742352e..cdc8c0d 100644 --- a/src/IBKRTrader.Hosting/AppHostBuilder.cs +++ b/src/IBKRTrader.Hosting/AppHostBuilder.cs @@ -69,7 +69,11 @@ public static class AppHostBuilder foreach (var module in modules) { services.AddSingleton(module); - module.RegisterServices(services, context.Configuration); + // Der Ausführungszugang wird dem Modul übergeben, statt im Container zu + // stehen: was dort steht, kann jedes Modul auflösen - auch den Zugang eines + // anderen Moduls und damit dessen Buch. + module.RegisterServices(services, context.Configuration, + new ModuleExecutionFactory(BookId.ForModule(BookNameOf(module)))); } configureExtra?.Invoke(services, context.Configuration); }) diff --git a/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs b/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs index 269355d..d20a9eb 100644 --- a/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs +++ b/src/IBKRTrader.Modules.CongressTrading/CongressTradingModule.cs @@ -26,7 +26,18 @@ public sealed class CongressTradingModule : IModule public string Name => "CongressTrading"; public string DbPrefix => "ct_"; - public void RegisterServices(IServiceCollection services, IConfiguration configuration) + public void RegisterServices(IServiceCollection services, IConfiguration configuration) => + Register(services, configuration, execution: null); + + /// + /// Der Weg, den der Host nimmt: mit dem an das Buch CT gebundenen Ausführungszugang. + /// + public void RegisterServices(IServiceCollection services, IConfiguration configuration, + ModuleExecutionFactory execution) => + Register(services, configuration, execution); + + private void Register(IServiceCollection services, IConfiguration configuration, + ModuleExecutionFactory? execution) { // Modul-Persistenz: eigener DbContext (ct_-Tabellen) in derselben MariaDB. var conn = ServiceCollectionExtensions.EffectiveConnectionString(configuration["Database:MySqlConnectionString"]); @@ -34,7 +45,13 @@ public sealed class CongressTradingModule : IModule services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + // Die Strategie bekommt den an CT gebundenen Ausführungszugang - nicht den + // gemeinsamen IExecutionService. Damit kann dieses Modul auf kein fremdes Buch handeln, + // auch nicht durch einen Fehler beim Setzen von SourceModule. + services.AddSingleton(sp => new Strategy.CongressTradingStrategy( + execution?.Create(sp) ?? throw new InvalidOperationException( + "CongressTrading braucht den gebundenen Ausführungszugang des Hosts."), + sp.GetRequiredService())); services.AddSingleton(); services.AddSingleton(); diff --git a/src/IBKRTrader.Modules.CongressTrading/Strategy/CongressTradingStrategy.cs b/src/IBKRTrader.Modules.CongressTrading/Strategy/CongressTradingStrategy.cs index 5c74125..2cba637 100644 --- a/src/IBKRTrader.Modules.CongressTrading/Strategy/CongressTradingStrategy.cs +++ b/src/IBKRTrader.Modules.CongressTrading/Strategy/CongressTradingStrategy.cs @@ -6,15 +6,16 @@ namespace IBKRTrader.Modules.CongressTrading.Strategy; /// /// Strategie des CongressTrading-Moduls: wandelt einen neu gescrapten Kongress-Trade in ein -/// um und übergibt es dem Core-. +/// um und übergibt es dem an das Buch CT gebundenen +/// des Cores. /// Risiko, Kurs, Order und Buchung macht der Core – das Modul liefert nur die Absicht. /// public class CongressTradingStrategy { - private readonly IExecutionService _execution; + private readonly IModuleExecution _execution; private readonly LoggingService _logger; - public CongressTradingStrategy(IExecutionService execution, LoggingService logger) + public CongressTradingStrategy(IModuleExecution execution, LoggingService logger) { _execution = execution; _logger = logger; @@ -31,6 +32,9 @@ public class CongressTradingStrategy { Symbol = trade.Ticker, Side = side.Value, + // Pflichtfeld des Signals, aber NICHT massgeblich: der gebundene Zugang setzt die + // Herkunft serverseitig und ueberschreibt, was hier steht. Solange TradeSignal das + // Feld verlangt, bleibt es - entfallen kann es erst, wenn kein Aufrufer es mehr fuellt. SourceModule = CongressTradingModule.LogTag, Reason = $"Kongress-{trade.TradeType}: {trade.MemberBioId} / {trade.IssuerName}" }; diff --git a/tests/IBKRTrader.Tests/Modules/CongressTradingStrategyTests.cs b/tests/IBKRTrader.Tests/Modules/CongressTradingStrategyTests.cs index bffbd22..90b9076 100644 --- a/tests/IBKRTrader.Tests/Modules/CongressTradingStrategyTests.cs +++ b/tests/IBKRTrader.Tests/Modules/CongressTradingStrategyTests.cs @@ -10,7 +10,7 @@ namespace IBKRTrader.Tests.Modules; [Trait("cat", "unit")] public class CongressTradingStrategyTests { - private readonly IExecutionService _execution = Substitute.For(); + private readonly IModuleExecution _execution = Substitute.For(); private CongressTradingStrategy CreateSut() { diff --git a/tests/IBKRTrader.Tests/Modules/ModuleBoundaryTests.cs b/tests/IBKRTrader.Tests/Modules/ModuleBoundaryTests.cs new file mode 100644 index 0000000..8867e32 --- /dev/null +++ b/tests/IBKRTrader.Tests/Modules/ModuleBoundaryTests.cs @@ -0,0 +1,120 @@ +using System.Reflection; +using FluentAssertions; +using IBKRTrader.Core.Trading; +using IBKRTrader.Modules.Accounting; +using IBKRTrader.Modules.CongressTrading; +using IBKRTrader.Modules.OptionsWheel; +using IBKRTrader.Modules.Supervisor; + +namespace IBKRTrader.Tests.Modules; + +/// +/// Die Grenze zwischen Modul und Core (Kapital- und Buchmodell §2, Regel 4): Ein Modul handelt +/// ausschließlich über seinen gebundenen -Zugang. Weder der Broker +/// noch die gemeinsame Ausführungs-Pipeline gehören in ein Modul. +/// +/// Warum als Test und nicht als Container-Trennung. Getrennte DI-Container je Modul +/// wären der harte Weg, aber sie ändern die Modularchitektur und kosten bei jedem Modul Aufwand. +/// Der Fall, um den es wirklich geht, ist ein neues Modul, das den Core versehentlich +/// umgeht – und genau den fängt dieser Test ab, beim Übersetzen statt im Betrieb. +/// +/// Heute hält sich jedes Modul daran. Ohne Prüfung fiele der erste Bruch niemandem auf. +/// +[Trait("cat", "unit")] +public class ModuleBoundaryTests +{ + /// Was ein Modul nicht anfassen darf. + private static readonly Type[] Forbidden = + [ + typeof(IBrokerClient), + typeof(IBrokerPortfolioReader), + typeof(IExecutionService) + ]; + + private static Assembly[] ModuleAssemblies => + [ + typeof(CongressTradingModule).Assembly, + typeof(AccountingModule).Assembly, + typeof(SupervisorModule).Assembly, + typeof(OptionsWheelModule).Assembly + ]; + + [Fact] + public void NoModuleTakesTheBrokerOrTheSharedPipelineThroughDependencyInjection() + { + var offences = new List(); + + foreach (var assembly in ModuleAssemblies) + foreach (var type in assembly.GetTypes()) + { + foreach (var ctor in type.GetConstructors( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + foreach (var parameter in ctor.GetParameters().Where(p => Forbidden.Contains(p.ParameterType))) + offences.Add($"{type.FullName}: Konstruktorparameter '{parameter.Name}' " + + $"vom Typ {parameter.ParameterType.Name}"); + + foreach (var field in type.GetFields( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static) + .Where(f => Forbidden.Contains(f.FieldType))) + offences.Add($"{type.FullName}: Feld '{field.Name}' vom Typ {field.FieldType.Name}"); + + foreach (var property in type.GetProperties( + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static) + .Where(p => Forbidden.Contains(p.PropertyType))) + offences.Add($"{type.FullName}: Eigenschaft '{property.Name}' " + + $"vom Typ {property.PropertyType.Name}"); + } + + offences.Should().BeEmpty( + "ein Modul handelt ausschließlich über seinen gebundenen IModuleExecution-Zugang. " + + "Wer den Broker oder die gemeinsame Pipeline nimmt, umgeht Buchführung, Kapitalbindung " + + "und Limits des Cores"); + } + + /// + /// Der zweite Weg an der Grenze vorbei: sich den verbotenen Dienst zur Laufzeit aus dem + /// Container holen. Das sieht die Reflection oben nicht – dafür steht es im Quelltext. + /// + /// Findet der Test die Quellen nicht (etwa in einer Auslieferung ohne Repository), prüft + /// er nichts und schlägt auch nicht fehl; im Repository und in der CI greift er. + /// + [Fact] + public void NoModuleResolvesTheBrokerOrTheSharedPipelineFromTheContainer() + { + var moduleSources = FindModuleSourceDirectories(); + if (moduleSources.Count == 0) return; + + var offences = new List(); + + foreach (var directory in moduleSources) + foreach (var file in Directory.EnumerateFiles(directory, "*.cs", SearchOption.AllDirectories)) + { + var text = File.ReadAllText(file); + foreach (var type in Forbidden.Select(t => t.Name)) + if (text.Contains($"<{type}>", StringComparison.Ordinal) || + text.Contains($"({type} ", StringComparison.Ordinal)) + offences.Add($"{Path.GetFileName(file)}: nennt {type}"); + } + + offences.Should().BeEmpty( + "ein Modul darf sich den Broker oder die gemeinsame Pipeline auch nicht aus dem " + + "Container holen – der gebundene Zugang ist der einzige Weg"); + } + + /// Sucht die Modul-Quellverzeichnisse ausgehend vom Testverzeichnis. + private static List FindModuleSourceDirectories() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory is not null) + { + var src = Path.Combine(directory.FullName, "src"); + if (Directory.Exists(src)) + return [.. Directory.GetDirectories(src, "IBKRTrader.Modules.*")]; + + directory = directory.Parent; + } + + return []; + } +}