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"); + } +}