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