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