Files
IBKRTrader/tests/IBKRTrader.Tests/Trading/TradingHoursTests.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

157 lines
6.9 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.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();
}
}