using FluentAssertions; using IBKRTrader.Core.Trading; namespace IBKRTrader.Tests.Trading; /// /// 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. /// [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(); } }