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 []; + } +}