Files
IBKRTrader/tests/IBKRTrader.Tests/Modules/DebugTrading/DebugTradingLogicTests.cs
T
RichardandClaude Opus 5 362207c6f2 Handelszeiten im Core: DebugTrading handelt nicht mehr in die geschlossene Boerse
Das Modul erzeugte ueber das Wochenende Dutzende wartender Orders - alle 15
Minuten eine, alle von IBKR angenommen und fuer Montag 09:00 vorgemerkt, alle
gleichzeitig ausloesend. Am 2026-09-06 waren es 31.

Die Auskunft gehoert in den Core, nicht ins Modul: Handelszeiten braucht jede
Strategie, und sie ist nichts, was ein Modul selbst wissen kann.

- MarketHours.cs: MarketSession, MarketSchedule (IsOpen, NextOpenUtc), der
  Parser TradingHours und der Seam IMarketCalendar. Reine Rechnung, damit die
  Formate ohne Broker pruefbar sind - 20 Tests ueber beide Schreibweisen der
  TWS-API, Feiertage, Zeitraeume ueber Mitternacht und unbekannte Zonen.
- Die Zeiten kommen aus derselben Kontraktabfrage, die der Adapter ohnehin
  macht: contractDetails liefert sie mit, eine eigene Runde waere Verschwendung.
- LiquidHours und nicht TradingHours. Letzteres enthaelt bei US-Werten die vor-
  und nachboersliche Zeit; unsere Orders setzen OutsideRth nicht, wuerden dort
  also gar nicht ausgefuehrt, sondern warten - genau der Zustand, den das hier
  verhindern soll.
- Keine Ersatzzeitzone, wenn TWS eine unbekannte meldet. Mit der falschen Zone
  gerechnete Handelszeiten saehen richtig aus und laegen um Stunden daneben.
- Unbekannt gilt als geschlossen. Wer aus fehlender Auskunft auf einen offenen
  Markt schloesse, handelte bei jeder Stoerung ins Geschlossene.

DebugTrading.OnlyDuringMarketHours, Vorgabe an. Die Pruefung gilt fuer beide
Richtungen: Auch eine Schliessung erzeugte dieselbe wartende Order. Eine
Testposition ein paar Stunden laenger zu halten kostet dagegen nichts - und die
nicht geschlossene Position belegt ihren Platz weiter, sonst eroeffnete die
Planung ueber die eigene Grenze hinaus.

Auf der Testmaschine am Sonntagabend nachgewiesen:
  [20:31:28] Kein Instrument handelbar, naechste Eroeffnung SAP:EUR um
             2026-09-07 07:00 UTC.
07:00 UTC ist 09:00 MEZ - dieselbe Zeit, die IBKR heute frueh im Warntext 399
genannt hat. Zwei unabhaengige Quellen, gleiche Antwort. Seither keine neue
Order; der Lauf dauert zwei statt neunzig Sekunden.

524 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:33:45 +02:00

271 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
}
// ─── Geschlossene Börse (#502) ──────────────────────────────────────
private static readonly Func<InstrumentRef, bool> NichtsHandelbar = _ => false;
/// <summary>
/// Der Fall, um den es geht: Am Wochenende nimmt IBKR jede Order an und merkt sie für Montag
/// vor. Ein Modul im 15-Minuten-Takt sammelt so über ein Wochenende Dutzende an, die dann alle
/// auf einmal auslösen – am 2026-09-06 mit 31 offenen Orders beobachtet.
/// </summary>
[Fact]
public void WithTheExchangeClosed_NothingIsOpened()
{
var actions = DebugTradingPlan.Plan(
[], Now, 0m, Settings(), Price, new Random(1), NichtsHandelbar);
actions.Should().BeEmpty();
}
[Fact]
public void WithTheExchangeClosed_ADuePositionIsNotClosedEither()
{
// Eine Schließung erzeugte dieselbe wartende Order wie eine Eröffnung. Die Testposition ein
// paar Stunden länger zu halten, kostet dieses Modul dagegen nichts.
var sap = InstrumentRef.ForStock("SAP", "EUR");
var open = new[] { new OpenTestPosition(sap, 5, Now.AddMinutes(-1)) };
DebugTradingPlan.Plan(open, Now, 0m, Settings(), Price, new Random(1), NichtsHandelbar)
.Should().BeEmpty();
}
[Fact]
public void ABlockedCloseStillOccupiesItsSlot()
{
// Sonst rechnete die Planung den Platz als frei, den die nicht geschlossene Position
// weiterhin belegt - und eröffnete über die eigene Grenze hinaus, sobald ein anderes
// Instrument handelbar ist.
var sap = InstrumentRef.ForStock("SAP", "EUR");
var alv = InstrumentRef.ForStock("ALV", "EUR");
var settings = Settings();
settings.MaxOpenPositions = 3;
settings.Universe = ["ALV:EUR"];
var open = new[]
{
new OpenTestPosition(sap, 5, Now.AddMinutes(-1)), // fällig, aber SAP ist zu
new OpenTestPosition(sap, 5, Now.AddHours(5)),
new OpenTestPosition(sap, 5, Now.AddHours(9))
};
// Nur ALV ist handelbar - SAP weder schließbar noch eröffenbar.
var actions = DebugTradingPlan.Plan(
open, Now, 0m, settings, Price, new Random(1),
i => i.Key == "ALV:EUR");
actions.Should().BeEmpty("drei Plätze sind belegt, auch der der nicht geschlossenen Position");
}
[Fact]
public void OnlyTradableInstrumentsAreConsideredForOpening()
{
var settings = Settings();
settings.Universe = ["SAP:EUR", "AAPL:USD"];
var actions = DebugTradingPlan.Plan(
[], Now, 0m, settings, Price, new Random(1),
i => i.Key == "AAPL");
var opened = actions.Should().ContainSingle().Subject
.Should().BeOfType<DebugAction.Open>().Subject;
opened.Instrument.Key.Should().Be("AAPL", "SAP ist gesperrt, AAPL nicht");
}
[Fact]
public void WithoutTheCheck_NothingChanges()
{
// Die Vorgabe null heißt "ohne Prüfung" - damit bleibt jedes bestehende Verhalten gleich.
DebugTradingPlan.Plan([], Now, 0m, Settings(), Price, new Random(1), null)
.Should().ContainSingle();
}
}