DebugTrading: ein Modul, das absichtlich Last erzeugt

Alles, was den Handel absichert - Limits, Deckung, Kapitalpool, Reservierungen,
Abgleich, Sperren - war bisher ausschliesslich durch Unit-Tests mit gemockten
Gegenstellen belegt. Im Betrieb gab es fast keine Trades. Ein Fehler in der
Buchungskette faellt so erst auf, wenn eine echte Strategie scharf geht.

Das Modul eroeffnet kleine Positionen, haelt sie eine gewuerfelte Zeit von
Minuten bis Tagen und schliesst sie selbst. Es soll keine Rendite erzeugen,
sondern Last; die Auswahl ist zufaellig und ausdruecklich nicht renditegetrieben.

Planung und Sicherungen sind reine Rechnung mit einer von aussen gereichten
Zufallsquelle - genau deshalb ist ein Modul, das absichtlich wuerfelt, ueberhaupt
pruefbar. 14 Tests.

Die Sicherung, die nicht verhandelbar ist: Der Worker prueft bei JEDEM Lauf, ob
der Handelsmodus eindeutig Paper ist, und wirft sonst. Nicht nur beim Start -
Einstellungen werden kopiert, wenn eine Instanz umzieht. Geprueft wird gegen den
Rohwert UND den geparsten Modus: TradingSettings.ParsedMode faellt bei einem
unbekannten Wert auf Paper zurueck, und darauf darf sich diese Pruefung nicht
verlassen. Dazu Tagesbudget (in Betriebszeitzone, nicht UTC), Obergrenze offener
Positionen, Grenze je Trade und ein Not-Aus, der das Eroeffnen stoppt und offene
Positionen weiter betreut.

Schliessen hat Vorrang vor Eroeffnen: Sonst koennte eine erreichte Obergrenze
verhindern, dass faellige Positionen je geschlossen werden - die Sperre bliebe
fuer immer.

Beim Bauen hat der Architekturtest aus #474 seinen ersten echten Fang gemacht:
Das Modul griff nach IBrokerClient, um Kurse zu holen. Das verstoesst gegen
Regel 4. Statt den Test aufzuweichen ist eine lesende Kursquelle entstanden
(IQuoteSource) - wer einen Kurs braucht, soll dafuer nicht den Zugang bekommen,
mit dem sich Orders platzieren lassen.

Auf dem Testserver nachgewiesen: Das Modul laeuft im Takt, plant, wird bei
fehlendem Kurs sauber abgelehnt und meldet das - und mit Trading.Mode=Live
verweigert es den Dienst, waehrend unabhaengig davon auch der Broker-Adapter
den Paper-Port gegen den Live-Modus sperrt. Zwei Schichten, die nichts
voneinander wissen.

438 Tests gruen.

Arbeit an #468.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-05 14:27:58 +02:00
co-authored by Claude Opus 5
parent 27b594ca8f
commit 1dac482cc7
20 changed files with 1144 additions and 4 deletions
@@ -0,0 +1,189 @@
using FluentAssertions;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading;
using IBKRTrader.Modules.DebugTrading.Logic;
namespace IBKRTrader.Tests.Modules.DebugTrading;
/// <summary>
/// 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.
/// </summary>
[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<DebugAction.Open>().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<DebugAction.Close>();
}
[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<DebugAction.Open>().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");
}
}