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>
157 lines
6.9 KiB
C#
157 lines
6.9 KiB
C#
using FluentAssertions;
|
||
using IBKRTrader.Core.Trading;
|
||
|
||
namespace IBKRTrader.Tests.Trading;
|
||
|
||
/// <summary>
|
||
/// Die Handelszeiten-Zeichenketten der TWS-API (#502). Reine Rechnung, deshalb ohne Broker prüfbar –
|
||
/// und das muss sie sein: Ein falsch gedeuteter Zeitraum erlaubt Handel zu einer Zeit, zu der die
|
||
/// Börse zu ist, und genau das soll er verhindern.
|
||
/// </summary>
|
||
[Trait("cat", "unit")]
|
||
public class TradingHoursTests
|
||
{
|
||
// ─── Formate ────────────────────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void LongForm_WithDateOnBothSides_IsRead()
|
||
{
|
||
var schedule = TradingHours.Parse(
|
||
"20260907:0900-20260907:1730;20260908:0900-20260908:1730", "Europe/Berlin");
|
||
|
||
schedule.Should().NotBeNull();
|
||
schedule!.Sessions.Should().HaveCount(2);
|
||
|
||
// 09:00 Berliner Zeit im September = 07:00 UTC (Sommerzeit).
|
||
schedule.Sessions[0].OpenUtc.Should().Be(new DateTime(2026, 9, 7, 7, 0, 0, DateTimeKind.Utc));
|
||
schedule.Sessions[0].CloseUtc.Should().Be(new DateTime(2026, 9, 7, 15, 30, 0, DateTimeKind.Utc));
|
||
}
|
||
|
||
[Fact]
|
||
public void ShortForm_WithoutDate_TakesTheDayFromTheSegment()
|
||
{
|
||
var schedule = TradingHours.Parse("20260907:0900-1730,1900-2200", "Europe/Berlin");
|
||
|
||
schedule.Should().NotBeNull();
|
||
schedule!.Sessions.Should().HaveCount(2, "zwei Zeiträume an einem Tag");
|
||
schedule.Sessions[1].OpenUtc.Should().Be(new DateTime(2026, 9, 7, 17, 0, 0, DateTimeKind.Utc));
|
||
}
|
||
|
||
[Fact]
|
||
public void ClosedDays_AreSkipped_NotTreatedAsOpen()
|
||
{
|
||
var schedule = TradingHours.Parse("20260906:CLOSED;20260907:0900-20260907:1730", "Europe/Berlin");
|
||
|
||
schedule.Should().NotBeNull();
|
||
schedule!.Sessions.Should().ContainSingle();
|
||
schedule.IsOpen(new DateTime(2026, 9, 6, 12, 0, 0, DateTimeKind.Utc))
|
||
.Should().BeFalse("Sonntag ist geschlossen gemeldet");
|
||
}
|
||
|
||
[Fact]
|
||
public void AllDaysClosed_YieldsNoSchedule_WhichMeansUnknown()
|
||
{
|
||
// Wichtig fuer den Aufrufer: null heisst "keine Auskunft" und wird als geschlossen
|
||
// behandelt - dasselbe Ergebnis wie ein Wochenende, aber aus einem anderen Grund.
|
||
TradingHours.Parse("20260906:CLOSED;20260907:CLOSED", "Europe/Berlin").Should().BeNull();
|
||
}
|
||
|
||
[Fact]
|
||
public void ARangeAcrossMidnight_DoesNotCollapse()
|
||
{
|
||
// Kommt bei Futures vor: 17:00 bis 16:00 des Folgetags. Ohne Korrektur laege das Ende vor
|
||
// dem Anfang und der Zeitraum fiele stillschweigend weg.
|
||
var schedule = TradingHours.Parse("20260907:1700-1600", "America/New_York");
|
||
|
||
schedule.Should().NotBeNull();
|
||
var session = schedule!.Sessions.Single();
|
||
session.CloseUtc.Should().BeAfter(session.OpenUtc);
|
||
(session.CloseUtc - session.OpenUtc).Should().Be(TimeSpan.FromHours(23));
|
||
}
|
||
|
||
// ─── Zeitzonen ──────────────────────────────────────────────────────────────
|
||
|
||
[Theory]
|
||
[InlineData("US/Eastern")]
|
||
[InlineData("America/New_York")]
|
||
[InlineData("Europe/Berlin")]
|
||
[InlineData("MET")]
|
||
public void KnownZones_AreResolved(string zone) =>
|
||
TradingHours.Parse("20260907:0900-20260907:1730", zone).Should().NotBeNull();
|
||
|
||
[Fact]
|
||
public void AnUnknownZone_YieldsNothing_InsteadOfGuessing()
|
||
{
|
||
// Mit der falschen Zone gerechnete Handelszeiten sind schlimmer als gar keine: Sie sähen
|
||
// richtig aus und lägen um Stunden daneben.
|
||
TradingHours.Parse("20260907:0900-20260907:1730", "Gibt/EsNicht").Should().BeNull();
|
||
TradingHours.Parse("20260907:0900-20260907:1730", "").Should().BeNull();
|
||
TradingHours.Parse("20260907:0900-20260907:1730", null).Should().BeNull();
|
||
}
|
||
|
||
[Fact]
|
||
public void TheZoneIsAppliedToTheOpeningTime_NotTheHostClock()
|
||
{
|
||
// Dieselbe Uhrzeit in zwei Zonen muss zu verschiedenen UTC-Zeitpunkten führen. Sonst hinge
|
||
// das Ergebnis an der Kultur bzw. Zeitzone der Maschine – wir betreiben Instanzen in EU und US.
|
||
var berlin = TradingHours.Parse("20260907:0900-20260907:1730", "Europe/Berlin")!;
|
||
var newYork = TradingHours.Parse("20260907:0900-20260907:1730", "America/New_York")!;
|
||
|
||
newYork.Sessions[0].OpenUtc.Should().Be(berlin.Sessions[0].OpenUtc.AddHours(6));
|
||
}
|
||
|
||
// ─── Unlesbares ─────────────────────────────────────────────────────────────
|
||
|
||
[Theory]
|
||
[InlineData("")]
|
||
[InlineData(null)]
|
||
[InlineData("völliger Unsinn")]
|
||
[InlineData("20260907")]
|
||
[InlineData("20260907:")]
|
||
public void UnreadableInput_YieldsNothing(string? hours) =>
|
||
TradingHours.Parse(hours, "Europe/Berlin").Should().BeNull();
|
||
|
||
[Fact]
|
||
public void OneBrokenRange_DoesNotDiscardTheGoodOnes()
|
||
{
|
||
var schedule = TradingHours.Parse("20260907:kaputt,0900-1730", "Europe/Berlin");
|
||
|
||
schedule.Should().NotBeNull();
|
||
schedule!.Sessions.Should().ContainSingle("der lesbare Zeitraum bleibt");
|
||
}
|
||
|
||
// ─── Auskunft ───────────────────────────────────────────────────────────────
|
||
|
||
[Fact]
|
||
public void IsOpen_AnswersForTheBoundaries()
|
||
{
|
||
var schedule = TradingHours.Parse("20260907:0900-20260907:1730", "Europe/Berlin")!;
|
||
|
||
schedule.IsOpen(new DateTime(2026, 9, 7, 6, 59, 0, DateTimeKind.Utc)).Should().BeFalse();
|
||
schedule.IsOpen(new DateTime(2026, 9, 7, 7, 0, 0, DateTimeKind.Utc)).Should().BeTrue("der Beginn zählt dazu");
|
||
schedule.IsOpen(new DateTime(2026, 9, 7, 15, 29, 0, DateTimeKind.Utc)).Should().BeTrue();
|
||
schedule.IsOpen(new DateTime(2026, 9, 7, 15, 30, 0, DateTimeKind.Utc)).Should().BeFalse("das Ende nicht mehr");
|
||
}
|
||
|
||
[Fact]
|
||
public void NextOpen_FindsTheComingSession()
|
||
{
|
||
var schedule = TradingHours.Parse(
|
||
"20260906:CLOSED;20260907:0900-20260907:1730;20260908:0900-20260908:1730", "Europe/Berlin")!;
|
||
|
||
var sunday = new DateTime(2026, 9, 6, 12, 0, 0, DateTimeKind.Utc);
|
||
|
||
schedule.NextOpenUtc(sunday).Should().Be(new DateTime(2026, 9, 7, 7, 0, 0, DateTimeKind.Utc));
|
||
}
|
||
|
||
[Fact]
|
||
public void NextOpen_IsNullBeyondWhatTheBrokerReported()
|
||
{
|
||
// Der Broker liefert nur wenige Tage im Voraus. "Kein nächster Beginn" heißt deshalb nicht
|
||
// "nie wieder" – wer das verwechselt, schaltet ein Modul dauerhaft ab.
|
||
var schedule = TradingHours.Parse("20260907:0900-20260907:1730", "Europe/Berlin")!;
|
||
|
||
schedule.NextOpenUtc(new DateTime(2026, 9, 30, 0, 0, 0, DateTimeKind.Utc)).Should().BeNull();
|
||
}
|
||
}
|