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