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>
271 lines
11 KiB
C#
271 lines
11 KiB
C#
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();
|
||
}
|
||
}
|