diff --git a/IBKRTrader.slnx b/IBKRTrader.slnx
index 1d03e84..a2c7da6 100644
--- a/IBKRTrader.slnx
+++ b/IBKRTrader.slnx
@@ -7,5 +7,6 @@
+
diff --git a/src/IBKRTrader.Core/Settings/AppSettings.cs b/src/IBKRTrader.Core/Settings/AppSettings.cs
index a58c88a..dcc19fa 100644
--- a/src/IBKRTrader.Core/Settings/AppSettings.cs
+++ b/src/IBKRTrader.Core/Settings/AppSettings.cs
@@ -309,6 +309,71 @@ public class CapitalSettings
$"Hausreserve {HouseReservePercent:0.#} %, max. {MaxSymbolPercent:0.#} % je Einzelwert";
}
+// ─── DebugTrading (Prüfmodul, niemals Live) ──────────────────────────────────
+
+///
+/// Einstellungen des DebugTrading-Moduls (#468). Es erzeugt absichtlich Last auf der
+/// Buchungskette – kleine Trades, gestreut über Handelsplätze, mit gewürfelter Haltedauer.
+///
+/// Die Grenzen hier sind die zweite Verteidigungslinie. Die erste ist die
+/// Startverweigerung im Modul selbst, wenn der Handelsmodus nicht Paper ist; sie lässt sich nicht
+/// wegkonfigurieren.
+///
+[TypeConverter(typeof(ExpandableObjectConverter))]
+public class DebugTradingSettings
+{
+ [Category("DebugTrading")]
+ [DisplayName("Aktiviert")]
+ [Description("Modulschalter. Aus = der Worker läuft nicht.")]
+ public bool Enabled { get; set; } = false;
+
+ [Category("DebugTrading")]
+ [DisplayName("Keine Neueröffnungen")]
+ [Description("Not-Aus: offene Testpositionen werden weiter geschlossen, aber keine neuen " +
+ "eröffnet. Der schonende Halt - Abschalten ließe offene Positionen unbetreut.")]
+ public bool StopOpening { get; set; } = false;
+
+ [Category("DebugTrading")]
+ [DisplayName("Takt (Minuten)")]
+ [Description("Abstand zwischen zwei Läufen. Je Lauf wird höchstens eine Position eröffnet.")]
+ public int IntervalMinutes { get; set; } = 15;
+
+ [Category("DebugTrading")]
+ [DisplayName("Max. offene Testpositionen")]
+ [Description("Obergrenze gleichzeitig offener Positionen dieses Moduls.")]
+ public int MaxOpenPositions { get; set; } = 5;
+
+ [Category("DebugTrading")]
+ [DisplayName("Max. Nominalwert je Trade")]
+ [Description("Obergrenze des Nominalwerts einer einzelnen Testposition, in Kontowährung.")]
+ public double MaxNotionalPerTrade { get; set; } = 1_000;
+
+ [Category("DebugTrading")]
+ [DisplayName("Tagesbudget")]
+ [Description("Summe der an einem Tag eröffneten Nominalwerte. Danach wird bis Mitternacht " +
+ "(Betriebszeitzone) nichts Neues mehr eröffnet.")]
+ public double DailyBudget { get; set; } = 10_000;
+
+ [Category("DebugTrading")]
+ [DisplayName("Haltedauer min. (Minuten)")]
+ [Description("Untergrenze der gewürfelten Haltedauer.")]
+ public int MinHoldMinutes { get; set; } = 10;
+
+ [Category("DebugTrading")]
+ [DisplayName("Haltedauer max. (Minuten)")]
+ [Description("Obergrenze der gewürfelten Haltedauer. Vorgabe 3 Tage - die lange Spanne ist " +
+ "der Zweck: sie prüft, ob offene Positionen Neustarts und Tageswechsel überstehen.")]
+ public int MaxHoldMinutes { get; set; } = 4_320;
+
+ [Category("DebugTrading")]
+ [DisplayName("Prüfuniversum")]
+ [Description("Instrumente, aus denen zufällig gewählt wird - Schreibweise \"SYMBOL:WÄHRUNG\". " +
+ "Bewusst über Währungen gestreut: das prüft Kontraktauflösung, Umrechnung und " +
+ "die Zeitzonenbehandlung der Ausführungszeiten mit.")]
+ public List Universe { get; set; } =
+ ["SAP:EUR", "ALV:EUR", "BAS:EUR", "AAPL:USD", "MSFT:USD"];
+}
+
// ─── Accounting ──────────────────────────────────────────────────────────────
[TypeConverter(typeof(ExpandableObjectConverter))]
@@ -465,6 +530,12 @@ public class AppSettings
[Description("KI-Analyse/Forensik (OpenRouter, read-only)")]
public SupervisorSettings Supervisor { get; set; } = new();
+ ///
+ /// Prüfmodul, das absichtlich Last erzeugt (#468). Vorgabe: aus. Es verweigert den Dienst
+ /// ohnehin, sobald der Handelsmodus nicht Paper ist.
+ ///
+ public DebugTradingSettings DebugTrading { get; set; } = new();
+
[Category("Deploymentcenter")]
[DisplayName("Deploymentcenter")]
[Description("Lizenz, Watchdog, UpdateService, Fehler-Stream (https://dc.mhdf.de)")]
diff --git a/src/IBKRTrader.Core/Trading/IBrokerPortfolioReader.cs b/src/IBKRTrader.Core/Trading/IBrokerPortfolioReader.cs
index 1788690..4f95873 100644
--- a/src/IBKRTrader.Core/Trading/IBrokerPortfolioReader.cs
+++ b/src/IBKRTrader.Core/Trading/IBrokerPortfolioReader.cs
@@ -21,3 +21,18 @@ public interface IBrokerPortfolioReader
///
Task> GetExecutionsAsync(DateTime? since = null, CancellationToken ct = default);
}
+
+///
+/// Nur Kurse, nichts sonst.
+///
+/// Getrennt von , weil dort das Auslösen von Handel wohnt. Ein
+/// Modul, das einen Kurs braucht, um eine Stückzahl zu rechnen, soll dafür nicht den Zugang
+/// bekommen, mit dem sich Orders platzieren lassen (Kapitalmodell §2 Regel 4). Die Trennung
+/// wurde am 2026-09-05 nötig, als das Prüfmodul Kurse brauchte und der Architekturtest den
+/// Griff nach IBrokerClient zu Recht abgelehnt hat.
+///
+public interface IQuoteSource
+{
+ /// Aktueller Kurs; null, wenn keiner vorliegt.
+ Task GetQuoteAsync(InstrumentRef instrument, CancellationToken ct = default);
+}
diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs
index 76bdbf7..324920c 100644
--- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs
+++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs
@@ -15,7 +15,7 @@ namespace IBKRTrader.Core.Trading.Ibkr;
/// fehlgeschlagene Order). Ein Konto mit Wert 0 lässt die Risikoprüfung jedes Signal ablehnen –
/// die sichere Richtung, wenn der Broker nicht erreichbar ist.
///
-public sealed class IbkrBrokerClient : IBrokerClient, IBrokerPortfolioReader, IOptionMarketData, IDisposable
+public sealed class IbkrBrokerClient : IBrokerClient, IBrokerPortfolioReader, IOptionMarketData, IQuoteSource, IDisposable
{
private const string LogModule = "IBKR";
diff --git a/src/IBKRTrader.Core/Trading/NullBrokerClient.cs b/src/IBKRTrader.Core/Trading/NullBrokerClient.cs
index ecfe2b1..4e3319b 100644
--- a/src/IBKRTrader.Core/Trading/NullBrokerClient.cs
+++ b/src/IBKRTrader.Core/Trading/NullBrokerClient.cs
@@ -7,7 +7,7 @@ namespace IBKRTrader.Core.Trading;
/// Wird registriert, bis der echte IBKR-Adapter angebunden und gegen den
/// Paper-Gateway verifiziert ist. So kann keine Order versehentlich rausgehen.
///
-public sealed class NullBrokerClient : IBrokerClient, IBrokerPortfolioReader, IOptionMarketData
+public sealed class NullBrokerClient : IBrokerClient, IBrokerPortfolioReader, IOptionMarketData, IQuoteSource
{
private readonly LoggingService _logger;
diff --git a/src/IBKRTrader.Hosting/AppHostBuilder.cs b/src/IBKRTrader.Hosting/AppHostBuilder.cs
index 8ad8698..dac6817 100644
--- a/src/IBKRTrader.Hosting/AppHostBuilder.cs
+++ b/src/IBKRTrader.Hosting/AppHostBuilder.cs
@@ -19,6 +19,7 @@ using IBKRTrader.Core.Workers.BuiltIn;
using IBKRTrader.Hosting.Deploymentcenter;
using IBKRTrader.Modules.Accounting;
using IBKRTrader.Modules.CongressTrading;
+using IBKRTrader.Modules.DebugTrading;
using IBKRTrader.Modules.OptionsWheel;
using IBKRTrader.Modules.Supervisor;
using Microsoft.Extensions.Configuration;
@@ -44,6 +45,11 @@ public static class AppHostBuilder
[
new CongressTradingModule(),
new OptionsWheelModule(),
+
+ // Pruefmodul (#468). Es verweigert den Dienst, sobald der Handelsmodus nicht Paper ist,
+ // und ist per Vorgabe abgeschaltet - registriert wird es trotzdem immer, damit sein
+ // Zustand in der Workers-Ansicht sichtbar bleibt statt unsichtbar zu fehlen.
+ new DebugTradingModule(),
new AccountingModule(),
new SupervisorModule()
];
@@ -266,6 +272,7 @@ public static class AppHostBuilder
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddSingleton(sp => sp.GetRequiredService());
}
else
{
@@ -273,6 +280,7 @@ public static class AppHostBuilder
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton(sp => sp.GetRequiredService());
services.AddSingleton(sp => sp.GetRequiredService());
+ services.AddSingleton(sp => sp.GetRequiredService());
}
// Core-Worker/Services
diff --git a/src/IBKRTrader.Hosting/IBKRTrader.Hosting.csproj b/src/IBKRTrader.Hosting/IBKRTrader.Hosting.csproj
index 91118ea..2834688 100644
--- a/src/IBKRTrader.Hosting/IBKRTrader.Hosting.csproj
+++ b/src/IBKRTrader.Hosting/IBKRTrader.Hosting.csproj
@@ -28,6 +28,7 @@
Bugtracker. Interimslösung, solange kein NuGet-Paket in der Gitea-Registry liegt - siehe
docs/archiv/KONZEPT-Deploymentcenter-Integration.md §2.2. -->
+
@@ -39,8 +40,7 @@
-
+
+ net10.0
+ enable
+ enable
+ en
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+ <_Parameter1>IBKRTrader.Tests
+
+
+
+
diff --git a/src/IBKRTrader.Modules.DebugTrading/Logic/DebugTradingGuard.cs b/src/IBKRTrader.Modules.DebugTrading/Logic/DebugTradingGuard.cs
new file mode 100644
index 0000000..8f56334
--- /dev/null
+++ b/src/IBKRTrader.Modules.DebugTrading/Logic/DebugTradingGuard.cs
@@ -0,0 +1,63 @@
+using IBKRTrader.Core.Trading;
+
+namespace IBKRTrader.Modules.DebugTrading.Logic;
+
+///
+/// Die Sicherungen des Prüfmoduls – reine Rechnung, damit sie ohne Broker und ohne Datenbank
+/// prüfbar sind.
+///
+/// Die erste ist nicht verhandelbar: Das Modul arbeitet ausschließlich im
+/// Paper-Modus. Nicht als Einstellung, die jemand umlegen kann, sondern als Bedingung, die bei
+/// jedem Lauf neu geprüft wird. Ein Testmodul, das auf einem Live-Konto anläuft, wäre der
+/// teuerste denkbare Fehler dieses Projekts – und Einstellungen werden kopiert, wenn eine
+/// Instanz von der Test- auf die Live-Maschine umzieht.
+///
+public static class DebugTradingGuard
+{
+ ///
+ /// Prüft den Handelsmodus. Liefert null, wenn gearbeitet werden darf, sonst den Grund.
+ ///
+ /// Bewusst gegen den geparsten Modus und nicht gegen die Zeichenkette: Ein unbekannter
+ /// Wert fällt in TradingSettings.ParsedMode auf Paper zurück, und genau darauf will
+ /// sich diese Prüfung nicht verlassen. Sie sieht den Rohwert mit an.
+ ///
+ public static string? CheckPaperMode(TradingMode mode, string rawMode)
+ {
+ if (mode != TradingMode.Paper)
+ return $"Handelsmodus ist {mode} – das Prüfmodul arbeitet ausschließlich im Paper-Modus.";
+
+ // Der Rohwert muss ebenfalls eindeutig Paper sagen. "Papier", "paper-live" oder ein
+ // Tippfehler ergeben sonst über den Rückfall ein stilles Ja.
+ if (!string.Equals(rawMode?.Trim(), nameof(TradingMode.Paper), StringComparison.OrdinalIgnoreCase))
+ return $"Handelsmodus \"{rawMode}\" ist nicht eindeutig Paper – das Prüfmodul bleibt stehen.";
+
+ return null;
+ }
+
+ ///
+ /// Prüft, ob eine neue Position eröffnet werden darf. null = ja.
+ ///
+ /// Aktuell offene Testpositionen.
+ /// Obergrenze.
+ /// Heute eröffneter Nominalwert.
+ /// Tagesbudget.
+ /// Nominalwert der geplanten Position.
+ /// Obergrenze je Position.
+ public static string? CheckOpening(
+ int openPositions, int maxOpen,
+ decimal spentToday, decimal dailyBudget,
+ decimal plannedNotional, decimal maxPerTrade)
+ {
+ if (openPositions >= maxOpen)
+ return $"Bereits {openPositions} offene Testpositionen (Grenze {maxOpen}).";
+
+ if (plannedNotional > maxPerTrade)
+ return $"Nominalwert {plannedNotional:F0} über der Grenze je Trade ({maxPerTrade:F0}).";
+
+ if (spentToday + plannedNotional > dailyBudget)
+ return $"Tagesbudget erschöpft: heute {spentToday:F0}, geplant {plannedNotional:F0}, " +
+ $"Budget {dailyBudget:F0}.";
+
+ return null;
+ }
+}
diff --git a/src/IBKRTrader.Modules.DebugTrading/Logic/DebugTradingPlan.cs b/src/IBKRTrader.Modules.DebugTrading/Logic/DebugTradingPlan.cs
new file mode 100644
index 0000000..0226dbb
--- /dev/null
+++ b/src/IBKRTrader.Modules.DebugTrading/Logic/DebugTradingPlan.cs
@@ -0,0 +1,117 @@
+using IBKRTrader.Core.Settings;
+using IBKRTrader.Core.Trading;
+
+namespace IBKRTrader.Modules.DebugTrading.Logic;
+
+/// Eine offene Testposition, so weit die Planung sie kennen muss.
+/// Gehaltenes Instrument.
+/// Stückzahl, immer positiv – das Modul geht nur long.
+/// Zeitpunkt, zu dem sie geschlossen werden soll.
+public sealed record OpenTestPosition(InstrumentRef Instrument, int Quantity, DateTime CloseAtUtc);
+
+/// Was das Modul in diesem Lauf tun soll.
+public abstract record DebugAction
+{
+ /// Eine fällige Position schließen.
+ public sealed record Close(OpenTestPosition Position, string Reason) : DebugAction;
+
+ /// Eine neue Position eröffnen und bis halten.
+ public sealed record Open(InstrumentRef Instrument, int Quantity, DateTime CloseAtUtc) : DebugAction;
+}
+
+///
+/// Entscheidet, was das Prüfmodul als Nächstes tut – reine Rechnung, ohne Broker, ohne
+/// Datenbank und mit einer von außen gereichten Zufallsquelle. Genau deshalb ist der gesamte
+/// Ablauf eines Moduls, das zufällig handelt, überhaupt prüfbar.
+///
+/// Schließen hat Vorrang vor Eröffnen. Läuft das Modul in eine Grenze, soll es
+/// zuerst aufräumen. Andernfalls könnte eine erreichte Obergrenze verhindern, dass fällige
+/// Positionen je geschlossen werden – die Sperre bliebe für immer.
+///
+public static class DebugTradingPlan
+{
+ ///
+ /// Plant den nächsten Lauf.
+ ///
+ /// Aktuell offene Testpositionen.
+ /// Jetzt.
+ /// Heute bereits eröffneter Nominalwert.
+ ///
+ /// Kurs je Instrument; null, wenn keiner vorliegt. Ohne Kurs wird nicht eröffnet – eine
+ /// Stückzahl ohne Preis wäre geraten.
+ ///
+ /// Zufallsquelle; in Tests fest vorgegeben.
+ public static IReadOnlyList Plan(
+ IReadOnlyList open,
+ DateTime nowUtc,
+ decimal spentToday,
+ DebugTradingSettings settings,
+ Func priceOf,
+ Random random)
+ {
+ var actions = new List();
+
+ // 1. Fällige Positionen schließen - immer und unabhängig von allen Grenzen.
+ foreach (var position in open.Where(p => p.CloseAtUtc <= nowUtc))
+ actions.Add(new DebugAction.Close(position,
+ $"Haltedauer abgelaufen ({position.CloseAtUtc:yyyy-MM-dd HH:mm} UTC)."));
+
+ // 2. Eröffnen nur, wenn alle Grenzen es zulassen.
+ if (settings.StopOpening) return actions;
+
+ var stillOpen = open.Count - actions.Count;
+ if (stillOpen >= settings.MaxOpenPositions) return actions;
+ if (spentToday >= (decimal)settings.DailyBudget) return actions;
+
+ var universe = ParseUniverse(settings.Universe);
+ if (universe.Count == 0) return actions;
+
+ // Reihenfolge würfeln und den ersten nehmen, für den es einen brauchbaren Kurs gibt.
+ // Ein fehlender Kurs ist der Normalfall (geschlossene Börse, fehlendes Abo) und darf den
+ // Lauf nicht leer ausgehen lassen, solange ein anderer Wert handelbar ist.
+ foreach (var instrument in universe.OrderBy(_ => random.Next()))
+ {
+ if (priceOf(instrument) is not { } price || price <= 0m) continue;
+
+ var budgetLeft = Math.Min(
+ (decimal)settings.MaxNotionalPerTrade,
+ (decimal)settings.DailyBudget - spentToday);
+
+ var quantity = (int)Math.Floor(budgetLeft / price);
+ if (quantity < 1) continue; // zu teuer für die Grenze - der nächste Wert ist dran
+
+ var hold = TimeSpan.FromMinutes(random.Next(
+ Math.Max(1, settings.MinHoldMinutes),
+ Math.Max(2, settings.MaxHoldMinutes)));
+
+ actions.Add(new DebugAction.Open(instrument, quantity, nowUtc.Add(hold)));
+ break; // hoechstens eine Eroeffnung je Lauf
+ }
+
+ return actions;
+ }
+
+ ///
+ /// Liest das Prüfuniversum. Ein unlesbarer Eintrag wird übersprungen, nicht geraten – ein
+ /// falsch gedeutetes Symbol wäre ein Handel in einem Papier, das niemand gemeint hat.
+ ///
+ public static IReadOnlyList ParseUniverse(IEnumerable entries)
+ {
+ var result = new List();
+
+ foreach (var raw in entries)
+ {
+ if (string.IsNullOrWhiteSpace(raw)) continue;
+
+ var parts = raw.Split(':', StringSplitOptions.TrimEntries);
+ var symbol = parts[0];
+ var currency = parts.Length > 1 ? parts[1] : Currencies.Default;
+
+ if (symbol.Length == 0 || !Currencies.IsValid(currency)) continue;
+
+ result.Add(InstrumentRef.ForStock(symbol, currency));
+ }
+
+ return result;
+ }
+}
diff --git a/src/IBKRTrader.Modules.DebugTrading/Models/DebugEntities.cs b/src/IBKRTrader.Modules.DebugTrading/Models/DebugEntities.cs
new file mode 100644
index 0000000..a85967e
--- /dev/null
+++ b/src/IBKRTrader.Modules.DebugTrading/Models/DebugEntities.cs
@@ -0,0 +1,37 @@
+namespace IBKRTrader.Modules.DebugTrading.Models;
+
+///
+/// Eine offene Testposition (dbg_position).
+///
+/// Warum persistent. Die gewürfelte Haltedauer reicht bis zu mehreren Tagen - genau
+/// das ist der Zweck des Moduls. Läge der Plan nur im Speicher, verlöre jeder Neustart die
+/// Absicht, und die Position bliebe für immer offen. Sie ist echt; sie verschwindet nicht mit dem
+/// Prozess.
+///
+public class DebugPosition
+{
+ public long Id { get; set; }
+
+ /// Instrumentenschlüssel, wie ihn der Core führt ("SAP:EUR").
+ public string ContractKey { get; set; } = "";
+
+ public string Symbol { get; set; } = "";
+ public string Currency { get; set; } = "";
+ public int Quantity { get; set; }
+
+ /// Kurs beim Eröffnen - nur zur Nachvollziehbarkeit, gebucht wird im Core.
+ public decimal OpenPrice { get; set; }
+
+ public DateTime OpenedAt { get; set; }
+
+ /// Geplanter Schließzeitpunkt (UTC). Der Worker schließt ab hier.
+ public DateTime CloseAtUtc { get; set; }
+
+ /// Gesetzt, sobald geschlossen wurde - die Zeile bleibt als Spur stehen.
+ public DateTime? ClosedAt { get; set; }
+
+ /// Signal-ID der Eröffnung, für die Verkettung mit Journal und Trade-Historie.
+ public string? OpenSignalId { get; set; }
+
+ public string? Note { get; set; }
+}
diff --git a/src/IBKRTrader.Modules.DebugTrading/Persistence/Ef/DebugTradingDbContext.cs b/src/IBKRTrader.Modules.DebugTrading/Persistence/Ef/DebugTradingDbContext.cs
new file mode 100644
index 0000000..8c1c33e
--- /dev/null
+++ b/src/IBKRTrader.Modules.DebugTrading/Persistence/Ef/DebugTradingDbContext.cs
@@ -0,0 +1,32 @@
+using IBKRTrader.Modules.DebugTrading.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace IBKRTrader.Modules.DebugTrading.Persistence.Ef;
+
+/// Persistenz des Prüfmoduls: die dbg_-Tabellen in derselben Datenbank.
+public class DebugTradingDbContext : DbContext
+{
+ public DebugTradingDbContext(DbContextOptions options) : base(options) { }
+
+ public DbSet Positions => Set();
+
+ protected override void OnModelCreating(ModelBuilder b)
+ {
+ b.Entity(e =>
+ {
+ e.ToTable("dbg_position");
+ e.HasKey(x => x.Id);
+ e.Property(x => x.ContractKey).HasMaxLength(64);
+ e.Property(x => x.Symbol).HasMaxLength(32);
+ e.Property(x => x.Currency).HasMaxLength(8);
+ e.Property(x => x.OpenPrice).HasPrecision(18, 4);
+ e.Property(x => x.OpenSignalId).HasMaxLength(64);
+ e.Property(x => x.Note).HasMaxLength(255);
+
+ // Der Worker fragt bei jedem Lauf nach den offenen Zeilen.
+ e.HasIndex(x => x.ClosedAt);
+ });
+
+ base.OnModelCreating(b);
+ }
+}
diff --git a/src/IBKRTrader.Modules.DebugTrading/Persistence/Ef/DebugTradingDbContextFactory.cs b/src/IBKRTrader.Modules.DebugTrading/Persistence/Ef/DebugTradingDbContextFactory.cs
new file mode 100644
index 0000000..d9645a6
--- /dev/null
+++ b/src/IBKRTrader.Modules.DebugTrading/Persistence/Ef/DebugTradingDbContextFactory.cs
@@ -0,0 +1,27 @@
+using IBKRTrader.Core.Configuration;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace IBKRTrader.Modules.DebugTrading.Persistence.Ef;
+
+///
+/// Design-Time-Factory für das EF-Tooling (dotnet ef). Die Verbindung kommt aus der
+/// Umgebungsvariablen IBKRTRADER_MYSQL; im Betrieb wird sie nie benutzt – dort speist
+/// den Kontext.
+///
+public class DebugTradingDbContextFactory : IDesignTimeDbContextFactory
+{
+ public DebugTradingDbContext CreateDbContext(string[] args)
+ {
+ // Der Platzhalter meldet sich absichtlich nicht als root an: Eine fehlende Konfiguration
+ // soll als solche zu erkennen sein und nicht wie ein Rechteproblem aussehen (#487).
+ var conn = Environment.GetEnvironmentVariable("IBKRTRADER_MYSQL")
+ ?? "Server=localhost;Port=3306;Database=ibkrtrader;User ID=KEINE-DATENBANK-KONFIGURIERT;Password=;";
+
+ var options = new DbContextOptionsBuilder()
+ .UseMySql(conn, DatabaseServerVersion.Value)
+ .Options;
+
+ return new DebugTradingDbContext(options);
+ }
+}
diff --git a/src/IBKRTrader.Modules.DebugTrading/Persistence/Ef/Migrations/20260905122318_DebugTradingPositionen.Designer.cs b/src/IBKRTrader.Modules.DebugTrading/Persistence/Ef/Migrations/20260905122318_DebugTradingPositionen.Designer.cs
new file mode 100644
index 0000000..62d73f4
--- /dev/null
+++ b/src/IBKRTrader.Modules.DebugTrading/Persistence/Ef/Migrations/20260905122318_DebugTradingPositionen.Designer.cs
@@ -0,0 +1,84 @@
+//
+using System;
+using IBKRTrader.Modules.DebugTrading.Persistence.Ef;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace IBKRTrader.Modules.DebugTrading.Persistence.Ef.Migrations
+{
+ [DbContext(typeof(DebugTradingDbContext))]
+ [Migration("20260905122318_DebugTradingPositionen")]
+ partial class DebugTradingPositionen
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.13")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+
+ modelBuilder.Entity("IBKRTrader.Modules.DebugTrading.Models.DebugPosition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("CloseAtUtc")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ClosedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ContractKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasMaxLength(8)
+ .HasColumnType("varchar(8)");
+
+ b.Property("Note")
+ .HasMaxLength(255)
+ .HasColumnType("varchar(255)");
+
+ b.Property("OpenPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("OpenSignalId")
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("OpenedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Quantity")
+ .HasColumnType("int");
+
+ b.Property("Symbol")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("varchar(32)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClosedAt");
+
+ b.ToTable("dbg_position", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/IBKRTrader.Modules.DebugTrading/Persistence/Ef/Migrations/20260905122318_DebugTradingPositionen.cs b/src/IBKRTrader.Modules.DebugTrading/Persistence/Ef/Migrations/20260905122318_DebugTradingPositionen.cs
new file mode 100644
index 0000000..793a672
--- /dev/null
+++ b/src/IBKRTrader.Modules.DebugTrading/Persistence/Ef/Migrations/20260905122318_DebugTradingPositionen.cs
@@ -0,0 +1,59 @@
+using System;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IBKRTrader.Modules.DebugTrading.Persistence.Ef.Migrations
+{
+ ///
+ public partial class DebugTradingPositionen : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterDatabase()
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateTable(
+ name: "dbg_position",
+ columns: table => new
+ {
+ Id = table.Column(type: "bigint", nullable: false)
+ .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
+ ContractKey = table.Column(type: "varchar(64)", maxLength: 64, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Symbol = table.Column(type: "varchar(32)", maxLength: 32, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Currency = table.Column(type: "varchar(8)", maxLength: 8, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Quantity = table.Column(type: "int", nullable: false),
+ OpenPrice = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
+ OpenedAt = table.Column(type: "datetime(6)", nullable: false),
+ CloseAtUtc = table.Column(type: "datetime(6)", nullable: false),
+ ClosedAt = table.Column(type: "datetime(6)", nullable: true),
+ OpenSignalId = table.Column(type: "varchar(64)", maxLength: 64, nullable: true)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Note = table.Column(type: "varchar(255)", maxLength: 255, nullable: true)
+ .Annotation("MySql:CharSet", "utf8mb4")
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_dbg_position", x => x.Id);
+ })
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_dbg_position_ClosedAt",
+ table: "dbg_position",
+ column: "ClosedAt");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "dbg_position");
+ }
+ }
+}
diff --git a/src/IBKRTrader.Modules.DebugTrading/Persistence/Ef/Migrations/DebugTradingDbContextModelSnapshot.cs b/src/IBKRTrader.Modules.DebugTrading/Persistence/Ef/Migrations/DebugTradingDbContextModelSnapshot.cs
new file mode 100644
index 0000000..a025140
--- /dev/null
+++ b/src/IBKRTrader.Modules.DebugTrading/Persistence/Ef/Migrations/DebugTradingDbContextModelSnapshot.cs
@@ -0,0 +1,81 @@
+//
+using System;
+using IBKRTrader.Modules.DebugTrading.Persistence.Ef;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace IBKRTrader.Modules.DebugTrading.Persistence.Ef.Migrations
+{
+ [DbContext(typeof(DebugTradingDbContext))]
+ partial class DebugTradingDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.13")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+
+ modelBuilder.Entity("IBKRTrader.Modules.DebugTrading.Models.DebugPosition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id"));
+
+ b.Property("CloseAtUtc")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ClosedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ContractKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasMaxLength(8)
+ .HasColumnType("varchar(8)");
+
+ b.Property("Note")
+ .HasMaxLength(255)
+ .HasColumnType("varchar(255)");
+
+ b.Property("OpenPrice")
+ .HasPrecision(18, 4)
+ .HasColumnType("decimal(18,4)");
+
+ b.Property("OpenSignalId")
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("OpenedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Quantity")
+ .HasColumnType("int");
+
+ b.Property("Symbol")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("varchar(32)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClosedAt");
+
+ b.ToTable("dbg_position", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/IBKRTrader.Modules.DebugTrading/Workers/DebugTradingWorker.cs b/src/IBKRTrader.Modules.DebugTrading/Workers/DebugTradingWorker.cs
new file mode 100644
index 0000000..5c88371
--- /dev/null
+++ b/src/IBKRTrader.Modules.DebugTrading/Workers/DebugTradingWorker.cs
@@ -0,0 +1,247 @@
+using IBKRTrader.Core.Logging;
+using IBKRTrader.Core.Persistence.Ef;
+using IBKRTrader.Core.Settings;
+using IBKRTrader.Core.Time;
+using IBKRTrader.Core.Trading;
+using IBKRTrader.Core.Workers;
+using IBKRTrader.Modules.DebugTrading.Logic;
+using IBKRTrader.Modules.DebugTrading.Models;
+using IBKRTrader.Modules.DebugTrading.Persistence.Ef;
+using Microsoft.EntityFrameworkCore;
+
+namespace IBKRTrader.Modules.DebugTrading.Workers;
+
+///
+/// Der Taktgeber des Prüfmoduls: schließt fällige Testpositionen und eröffnet gelegentlich eine
+/// neue.
+///
+/// Was dieses Modul soll und was nicht. Es soll Last erzeugen, keine Rendite. Die
+/// Auswahl ist zufällig und ausdrücklich nicht renditegetrieben – wer hier eine Strategie
+/// hineinbaut, macht aus dem Prüfwerkzeug ein Handelsmodul mit unklarer Absicht.
+///
+/// Die Reihenfolge ist Absicht: zuerst schließen, dann eröffnen. Sonst könnte eine
+/// erreichte Obergrenze verhindern, dass fällige Positionen je geschlossen werden.
+///
+public class DebugTradingWorker : WorkerBase
+{
+ private readonly IModuleExecution _execution;
+ private readonly IQuoteSource _quotes;
+ private readonly SettingsService _settings;
+ private readonly IDbContextFactory _own;
+ private readonly Random _random = new();
+
+ public override string Name => "DBG-TradeGenerator";
+ public override string Module => DebugTradingModule.LogTag;
+ public override WorkerType Type => WorkerType.Worker;
+
+ private DebugTradingSettings Config => _settings.Settings.DebugTrading;
+
+ protected override TimeSpan? Interval => TimeSpan.FromMinutes(Math.Max(1, Config.IntervalMinutes));
+
+ public DebugTradingWorker(
+ LoggingService logger,
+ IDbContextFactory coreDb,
+ IDbContextFactory own,
+ IModuleExecution execution,
+ IQuoteSource quotes,
+ SettingsService settings)
+ : base(logger, coreDb)
+ {
+ _own = own;
+ _execution = execution;
+ _quotes = quotes;
+ _settings = settings;
+ Info.Active = settings.Settings.DebugTrading.Enabled;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken ct)
+ {
+ // Die Modusprüfung bei JEDEM Lauf, nicht nur beim Start: Einstellungen werden kopiert,
+ // wenn eine Instanz von der Test- auf eine andere Maschine umzieht, und sie lassen sich
+ // im laufenden Betrieb ändern. Ein Fehlschlag ist hier das richtige Ergebnis - er ist in
+ // der Workers-Ansicht rot und im Log ein Fehler, statt still nichts zu tun.
+ var trading = _settings.Settings.Trading;
+ if (DebugTradingGuard.CheckPaperMode(trading.ParsedMode, trading.Mode) is { } blocked)
+ throw new InvalidOperationException($"DebugTrading verweigert den Dienst: {blocked}");
+
+ var open = await LoadOpenAsync(ct);
+ var spentToday = await SpentTodayAsync(ct);
+ var quotes = new Dictionary(StringComparer.Ordinal);
+
+ var actions = DebugTradingPlan.Plan(
+ [.. open.Select(ToPlanned)],
+ DateTime.UtcNow, spentToday, Config,
+ instrument => QuoteOf(instrument, quotes, ct),
+ _random);
+
+ if (actions.Count == 0)
+ {
+ Info.Info = $"{open.Count} offen, heute {spentToday:F0} eröffnet – nichts zu tun.";
+ return;
+ }
+
+ var closed = 0;
+ var opened = 0;
+
+ foreach (var action in actions)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ switch (action)
+ {
+ case DebugAction.Close close:
+ if (await CloseAsync(open, close, ct)) closed++;
+ break;
+
+ case DebugAction.Open openAction:
+ if (await OpenAsync(openAction, open.Count - closed, spentToday, quotes, ct)) opened++;
+ break;
+ }
+ }
+
+ Info.Info = $"{closed} geschlossen, {opened} eröffnet ({open.Count - closed + opened} offen).";
+ Logger.Info(Module, $"Prüflauf: {Info.Info}");
+ }
+
+ // ─── Eröffnen und Schließen ──────────────────────────────────────────────────
+
+ private async Task OpenAsync(
+ DebugAction.Open action, int openCount, decimal spentToday,
+ Dictionary quotes, CancellationToken ct)
+ {
+ var price = QuoteOf(action.Instrument, quotes, ct);
+ if (price is not { } unit || unit <= 0m) return false;
+
+ var notional = action.Quantity * unit;
+
+ // Zweite Verteidigungslinie, nach der Planung: Die Grenzen werden hier noch einmal gegen
+ // die tatsächlichen Zahlen geprüft, nicht gegen die geplanten.
+ if (DebugTradingGuard.CheckOpening(
+ openCount, Config.MaxOpenPositions, spentToday, (decimal)Config.DailyBudget,
+ notional, (decimal)Config.MaxNotionalPerTrade) is { } stop)
+ {
+ Logger.Info(Module, $"Keine Eröffnung: {stop}");
+ return false;
+ }
+
+ var signal = new TradeSignal
+ {
+ Symbol = action.Instrument.Symbol,
+ Currency = action.Instrument.Currency,
+ Side = TradeSide.Buy,
+ SourceModule = DebugTradingModule.LogTag,
+ SuggestedQuantity = action.Quantity,
+ Reason = $"Prüflast: {action.Quantity} Stück, Haltedauer bis {action.CloseAtUtc:yyyy-MM-dd HH:mm} UTC"
+ };
+
+ var result = await _execution.ExecuteAsync(signal, ct);
+ if (!result.Executed)
+ {
+ // Eine Ablehnung ist hier kein Fehler, sondern der halbe Zweck des Moduls: Sie zeigt,
+ // dass Limits, Deckung und Sperren greifen. Sie gehört sichtbar ins Log.
+ Logger.Info(Module, $"Eröffnung abgelehnt ({action.Instrument.Key}): {result.Reason}");
+ return false;
+ }
+
+ await using var db = await _own.CreateDbContextAsync(ct);
+ db.Positions.Add(new DebugPosition
+ {
+ ContractKey = action.Instrument.Key,
+ Symbol = action.Instrument.Symbol,
+ Currency = action.Instrument.Currency,
+ Quantity = action.Quantity,
+ OpenPrice = unit,
+ OpenedAt = DateTime.UtcNow,
+ CloseAtUtc = action.CloseAtUtc,
+ OpenSignalId = signal.SignalId,
+ Note = "Prüflast"
+ });
+ await db.SaveChangesAsync(ct);
+
+ Logger.Info(Module,
+ $"Eröffnet: {action.Quantity}x {action.Instrument.Key} zu {unit:F2} " +
+ $"({notional:F0} {action.Instrument.Currency}), hält bis {action.CloseAtUtc:yyyy-MM-dd HH:mm} UTC.");
+ return true;
+ }
+
+ private async Task CloseAsync(
+ IReadOnlyList open, DebugAction.Close action, CancellationToken ct)
+ {
+ var row = open.FirstOrDefault(p => p.ContractKey == action.Position.Instrument.Key
+ && p.ClosedAt is null);
+ if (row is null) return false;
+
+ var signal = new TradeSignal
+ {
+ Symbol = action.Position.Instrument.Symbol,
+ Currency = action.Position.Instrument.Currency,
+ Side = TradeSide.Sell,
+ SourceModule = DebugTradingModule.LogTag,
+ SuggestedQuantity = row.Quantity,
+ Reason = $"Prüflast schließen: {action.Reason}"
+ };
+
+ var result = await _execution.ExecuteAsync(signal, ct);
+ if (!result.Executed)
+ {
+ // Wichtig: Die Zeile bleibt offen. Beim nächsten Lauf wird es erneut versucht - eine
+ // Position, die wir für geschlossen halten, obwohl sie es nicht ist, wäre schlimmer
+ // als ein weiterer Versuch.
+ Logger.Warn(Module, $"Schließen abgelehnt ({row.ContractKey}): {result.Reason}");
+ return false;
+ }
+
+ await using var db = await _own.CreateDbContextAsync(ct);
+ var tracked = await db.Positions.FindAsync([row.Id], ct);
+ if (tracked is not null)
+ {
+ tracked.ClosedAt = DateTime.UtcNow;
+ await db.SaveChangesAsync(ct);
+ }
+
+ Logger.Info(Module, $"Geschlossen: {row.Quantity}x {row.ContractKey} – {action.Reason}");
+ return true;
+ }
+
+ // ─── Beschaffung ─────────────────────────────────────────────────────────────
+
+ private async Task> LoadOpenAsync(CancellationToken ct)
+ {
+ await using var db = await _own.CreateDbContextAsync(ct);
+ return await db.Positions.Where(p => p.ClosedAt == null).ToListAsync(ct);
+ }
+
+ ///
+ /// Heute eröffneter Nominalwert – Tagesgrenze in der Betriebszeitzone, nicht in UTC.
+ /// Sonst läge der Schnitt für eine EU-Instanz mitten am Nachmittag.
+ ///
+ private async Task SpentTodayAsync(CancellationToken ct)
+ {
+ var startLocal = AppTimeZone.Now.Date;
+ var startUtc = TimeZoneInfo.ConvertTimeToUtc(startLocal, AppTimeZone.Current);
+
+ await using var db = await _own.CreateDbContextAsync(ct);
+ return await db.Positions
+ .Where(p => p.OpenedAt >= startUtc)
+ .SumAsync(p => p.Quantity * p.OpenPrice, ct);
+ }
+
+ private static OpenTestPosition ToPlanned(DebugPosition row) =>
+ new(InstrumentRef.TryParse(row.ContractKey, out var instrument)
+ ? instrument
+ : InstrumentRef.ForStock(row.Symbol, row.Currency),
+ row.Quantity, row.CloseAtUtc);
+
+ /// Kurs mit Zwischenspeicher je Lauf – dieselbe Abfrage zweimal wäre reine Last.
+ private decimal? QuoteOf(
+ InstrumentRef instrument, Dictionary cache, CancellationToken ct)
+ {
+ if (cache.TryGetValue(instrument.Key, out var cached)) return cached;
+
+ var quote = _quotes.GetQuoteAsync(instrument, ct).GetAwaiter().GetResult();
+ decimal? price = quote is { Last: > 0 } ? quote.Last : null;
+
+ cache[instrument.Key] = price;
+ return price;
+ }
+}
diff --git a/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj b/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj
index 250c971..2a87987 100644
--- a/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj
+++ b/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj
@@ -27,6 +27,7 @@
+
diff --git a/tests/IBKRTrader.Tests/Modules/DebugTrading/DebugTradingLogicTests.cs b/tests/IBKRTrader.Tests/Modules/DebugTrading/DebugTradingLogicTests.cs
new file mode 100644
index 0000000..cd11048
--- /dev/null
+++ b/tests/IBKRTrader.Tests/Modules/DebugTrading/DebugTradingLogicTests.cs
@@ -0,0 +1,189 @@
+using FluentAssertions;
+using IBKRTrader.Core.Settings;
+using IBKRTrader.Core.Trading;
+using IBKRTrader.Modules.DebugTrading.Logic;
+
+namespace IBKRTrader.Tests.Modules.DebugTrading;
+
+///
+/// Planung und Sicherungen des Prüfmoduls (#468). Beides ist reine Rechnung mit einer von außen
+/// gereichten Zufallsquelle – genau deshalb ist ein Modul, das absichtlich zufällig handelt,
+/// überhaupt prüfbar.
+///
+[Trait("cat", "unit")]
+public class DebugTradingLogicTests
+{
+ private static readonly DateTime Now = new(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc);
+
+ private static DebugTradingSettings Settings() => new()
+ {
+ Enabled = true,
+ MaxOpenPositions = 3,
+ MaxNotionalPerTrade = 1_000,
+ DailyBudget = 5_000,
+ MinHoldMinutes = 10,
+ MaxHoldMinutes = 4_320,
+ Universe = ["SAP:EUR"]
+ };
+
+ private static decimal? Price(InstrumentRef _) => 100m;
+
+ // ─── Die Sicherung, die nicht verhandelbar ist ───────────────────────────────
+
+ [Fact]
+ public void LiveMode_IsRefused()
+ {
+ DebugTradingGuard.CheckPaperMode(TradingMode.Live, "Live")
+ .Should().NotBeNull("ein Prüfmodul auf einem Live-Konto wäre der teuerste Fehler dieses Projekts");
+ }
+
+ [Fact]
+ public void PaperMode_IsAllowed()
+ {
+ DebugTradingGuard.CheckPaperMode(TradingMode.Paper, "Paper").Should().BeNull();
+ DebugTradingGuard.CheckPaperMode(TradingMode.Paper, "paper").Should().BeNull();
+ }
+
+ [Fact]
+ public void AnAmbiguousModeValue_IsRefusedEvenThoughItParsesToPaper()
+ {
+ // TradingSettings.ParsedMode fällt bei einem unbekannten Wert auf Paper zurück. Genau
+ // darauf darf sich diese Prüfung nicht verlassen: "Papier" oder ein Tippfehler ergäben
+ // sonst ein stilles Ja auf einer Maschine, deren Konfiguration jemand kopiert hat.
+ DebugTradingGuard.CheckPaperMode(TradingMode.Paper, "Papier").Should().NotBeNull();
+ DebugTradingGuard.CheckPaperMode(TradingMode.Paper, "").Should().NotBeNull();
+ }
+
+ // ─── Grenzen ────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void Opening_IsBlockedByEachLimitSeparately()
+ {
+ DebugTradingGuard.CheckOpening(3, 3, 0m, 5_000m, 500m, 1_000m)
+ .Should().Contain("offene Testpositionen");
+
+ DebugTradingGuard.CheckOpening(0, 3, 0m, 5_000m, 1_500m, 1_000m)
+ .Should().Contain("über der Grenze je Trade");
+
+ DebugTradingGuard.CheckOpening(0, 3, 4_800m, 5_000m, 500m, 1_000m)
+ .Should().Contain("Tagesbudget");
+
+ DebugTradingGuard.CheckOpening(1, 3, 1_000m, 5_000m, 500m, 1_000m)
+ .Should().BeNull("alles innerhalb der Grenzen");
+ }
+
+ // ─── Planung ────────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void DuePositions_AreClosed_EvenWhenTheLimitIsReached()
+ {
+ // Drei offene bei einer Grenze von drei: Die faellige MUSS trotzdem geschlossen werden.
+ // Sonst hielte die erreichte Grenze das Modul fuer immer in der Sperre - es koennte nie
+ // wieder unter die Grenze kommen.
+ var sap = InstrumentRef.ForStock("SAP", "EUR");
+ var open = new[]
+ {
+ new OpenTestPosition(sap, 5, Now.AddMinutes(-1)), // faellig
+ new OpenTestPosition(sap, 5, Now.AddHours(5)),
+ new OpenTestPosition(sap, 5, Now.AddHours(9))
+ };
+
+ var actions = DebugTradingPlan.Plan(open, Now, 0m, Settings(), Price, new Random(1));
+
+ actions.Should().Contain(a => a is DebugAction.Close);
+
+ // Der frei gewordene Platz darf sofort wieder genutzt werden - das ist Dauerlast,
+ // kein Widerspruch.
+ actions.Count(a => a is DebugAction.Open).Should().BeLessThanOrEqualTo(1);
+ }
+
+ [Fact]
+ public void AtTheLimitWithNothingDue_NothingHappens()
+ {
+ // Volle Grenze, nichts faellig: kein Schliessen, kein Eroeffnen.
+ var sap = InstrumentRef.ForStock("SAP", "EUR");
+ var open = new[]
+ {
+ new OpenTestPosition(sap, 5, Now.AddHours(3)),
+ new OpenTestPosition(sap, 5, Now.AddHours(5)),
+ new OpenTestPosition(sap, 5, Now.AddHours(9))
+ };
+
+ DebugTradingPlan.Plan(open, Now, 0m, Settings(), Price, new Random(1)).Should().BeEmpty();
+ }
+
+ [Fact]
+ public void WithRoomLeft_ExactlyOnePositionIsOpened()
+ {
+ var actions = DebugTradingPlan.Plan([], Now, 0m, Settings(), Price, new Random(1));
+
+ var open = actions.Should().ContainSingle().Subject.Should().BeOfType().Subject;
+ open.Quantity.Should().Be(10, "1.000 Grenze je Trade bei Kurs 100");
+ open.CloseAtUtc.Should().BeAfter(Now.AddMinutes(9)).And.BeBefore(Now.AddMinutes(4_321));
+ }
+
+ [Fact]
+ public void StopOpening_KeepsClosingButOpensNothing()
+ {
+ // Der Not-Aus: offene Positionen werden weiter betreut, aber nichts Neues eröffnet.
+ var settings = Settings();
+ settings.StopOpening = true;
+
+ var sap = InstrumentRef.ForStock("SAP", "EUR");
+ var open = new[] { new OpenTestPosition(sap, 5, Now.AddMinutes(-1)) };
+
+ var actions = DebugTradingPlan.Plan(open, Now, 0m, settings, Price, new Random(1));
+
+ actions.Should().ContainSingle().Which.Should().BeOfType();
+ }
+
+ [Fact]
+ public void WithoutAPrice_NothingIsOpened()
+ {
+ // Eine Stückzahl ohne Kurs wäre geraten.
+ var actions = DebugTradingPlan.Plan([], Now, 0m, Settings(), _ => null, new Random(1));
+
+ actions.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void TooExpensiveForTheLimit_IsSkipped()
+ {
+ // Ein Wert, von dem nicht einmal ein Stück ins Budget passt, wird übersprungen.
+ var actions = DebugTradingPlan.Plan([], Now, 0m, Settings(), _ => 5_000m, new Random(1));
+
+ actions.Should().BeEmpty();
+ }
+
+ [Fact]
+ public void TheDailyBudgetCapsTheSize()
+ {
+ // Nur noch 300 im Budget: die Position wird kleiner, nicht abgelehnt.
+ var actions = DebugTradingPlan.Plan([], Now, spentToday: 4_700m, Settings(), Price, new Random(1));
+
+ var open = actions.Should().ContainSingle().Subject.Should().BeOfType().Subject;
+ open.Quantity.Should().Be(3, "300 verbleibendes Budget bei Kurs 100");
+ }
+
+ // ─── Universum ──────────────────────────────────────────────────────────────
+
+ [Fact]
+ public void TheUniverseKeepsCurrenciesApart()
+ {
+ var parsed = DebugTradingPlan.ParseUniverse(["SAP:EUR", "AAPL:USD", "MSFT"]);
+
+ parsed.Should().HaveCount(3);
+ parsed[0].Key.Should().Be("SAP:EUR");
+ parsed[1].Key.Should().Be("AAPL", "USD bleibt im Schlüssel implizit");
+ parsed[2].Currency.Should().Be("USD", "ohne Angabe gilt die Vorgabewährung");
+ }
+
+ [Fact]
+ public void UnreadableEntriesAreSkipped_NotGuessed()
+ {
+ // Ein falsch gedeutetes Symbol wäre ein Handel in einem Papier, das niemand gemeint hat.
+ var parsed = DebugTradingPlan.ParseUniverse(["", " ", ":EUR", "SAP:NICHTSOWAS", "SAP:EUR"]);
+
+ parsed.Should().ContainSingle().Which.Key.Should().Be("SAP:EUR");
+ }
+}