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 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-05 12:59:44 +02:00
co-authored by Claude Opus 5
parent 49a17c2559
commit 3bccd90d97
7 changed files with 237 additions and 7 deletions
@@ -10,7 +10,7 @@ namespace IBKRTrader.Tests.Modules;
[Trait("cat", "unit")]
public class CongressTradingStrategyTests
{
private readonly IExecutionService _execution = Substitute.For<IExecutionService>();
private readonly IModuleExecution _execution = Substitute.For<IModuleExecution>();
private CongressTradingStrategy CreateSut()
{
@@ -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;
/// <summary>
/// Die Grenze zwischen Modul und Core (Kapital- und Buchmodell §2, Regel 4): Ein Modul handelt
/// ausschließlich über seinen gebundenen <see cref="IModuleExecution"/>-Zugang. Weder der Broker
/// noch die gemeinsame Ausführungs-Pipeline gehören in ein Modul.
///
/// <para><b>Warum als Test und nicht als Container-Trennung.</b> 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 <i>neues</i> Modul, das den Core versehentlich
/// umgeht – und genau den fängt dieser Test ab, beim Übersetzen statt im Betrieb.</para>
///
/// <para>Heute hält sich jedes Modul daran. Ohne Prüfung fiele der erste Bruch niemandem auf.</para>
/// </summary>
[Trait("cat", "unit")]
public class ModuleBoundaryTests
{
/// <summary>Was ein Modul nicht anfassen darf.</summary>
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<string>();
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");
}
/// <summary>
/// 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.
///
/// <para>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.</para>
/// </summary>
[Fact]
public void NoModuleResolvesTheBrokerOrTheSharedPipelineFromTheContainer()
{
var moduleSources = FindModuleSourceDirectories();
if (moduleSources.Count == 0) return;
var offences = new List<string>();
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");
}
/// <summary>Sucht die Modul-Quellverzeichnisse ausgehend vom Testverzeichnis.</summary>
private static List<string> 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 [];
}
}