diff --git a/src/IBKRTrader.Core/Settings/AppSettings.cs b/src/IBKRTrader.Core/Settings/AppSettings.cs index 56fe9f7..4b5b478 100644 --- a/src/IBKRTrader.Core/Settings/AppSettings.cs +++ b/src/IBKRTrader.Core/Settings/AppSettings.cs @@ -347,6 +347,14 @@ public class DebugTradingSettings "eröffnet. Der schonende Halt - Abschalten ließe offene Positionen unbetreut.")] public bool StopOpening { get; set; } = false; + [Category("DebugTrading")] + [DisplayName("Nur bei offener Börse")] + [Description("Vorgabe an: Es wird nur gehandelt, wenn der Broker das Instrument gerade als " + + "handelbar meldet. Aus wuerde bei geschlossener Boerse Orders erzeugen, die IBKR " + + "annimmt und bis zur naechsten Eroeffnung sammelt - die dann alle auf einmal " + + "ausloesen.")] + public bool OnlyDuringMarketHours { get; set; } = true; + [Category("DebugTrading")] [DisplayName("Takt (Minuten)")] [Description("Abstand zwischen zwei Läufen. Je Lauf wird höchstens eine Position eröffnet.")] diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs index 36b7edb..ed21b14 100644 --- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs +++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrBrokerClient.cs @@ -16,7 +16,8 @@ namespace IBKRTrader.Core.Trading.Ibkr; /// die sichere Richtung, wenn der Broker nicht erreichbar ist. /// public sealed class IbkrBrokerClient - : IBrokerClient, IBrokerPortfolioReader, IBrokerOrderBook, IOptionMarketData, IQuoteSource, IDisposable + : IBrokerClient, IBrokerPortfolioReader, IBrokerOrderBook, IOptionMarketData, IQuoteSource, + IMarketCalendar, IDisposable { private const string LogModule = "IBKR"; @@ -112,6 +113,13 @@ public sealed class IbkrBrokerClient return await _connection!.RequestExecutionsAsync(since, _requestTimeout, ct).ConfigureAwait(false); } + public async Task GetScheduleAsync(InstrumentRef instrument, CancellationToken ct = default) + { + if (!await IsReadyAsync(ct).ConfigureAwait(false)) return null; + + return await _connection!.RequestMarketScheduleAsync(instrument, _requestTimeout, ct).ConfigureAwait(false); + } + public async Task> GetOpenOrdersAsync(CancellationToken ct = default) { if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Array.Empty(); diff --git a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs index 9937bbf..ef1a03d 100644 --- a/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs +++ b/src/IBKRTrader.Core/Trading/Ibkr/IbkrConnection.cs @@ -669,6 +669,50 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable } } + /// + /// Handelszeiten eines Instruments, aus derselben Kontraktabfrage wie der Kontrakt selbst. + /// + /// LiquidHours und nicht TradingHours. TradingHours enthaelt bei US-Werten auch + /// die vor- und nachboersliche Zeit. Unsere Orders setzen OutsideRth nicht, werden dort + /// also gar nicht ausgefuehrt, sondern warten bis zur reguleren Eroeffnung - genau der Zustand, + /// den diese Abfrage vermeiden soll. Fehlt LiquidHours, gilt TradingHours als Rueckfall. + /// + public async Task RequestMarketScheduleAsync( + InstrumentRef instrument, TimeSpan timeout, CancellationToken ct) + { + var id = NextRequestId(); + var slot = new ContractSlot(); + _contracts[id] = slot; + try + { + var query = instrument.Option is { } option + ? IbkrMapping.Option(option) + : IbkrMapping.Stock(instrument.Symbol, instrument.Currency); + _socket.reqContractDetails(id, query); + + if (!await WaitAsync(slot.Done.Task, timeout, ct).ConfigureAwait(false) || + slot.Error is not null || slot.First is null) + { + _logger.Info(LogModule, $"Handelszeiten fuer {instrument.Key} nicht ermittelbar."); + return null; + } + + var schedule = TradingHours.Parse(slot.LiquidHours, slot.TimeZoneId) + ?? TradingHours.Parse(slot.TradingHours, slot.TimeZoneId); + + if (schedule is null) + _logger.Info(LogModule, + $"Handelszeiten fuer {instrument.Key} nicht lesbar " + + $"(Zone \"{slot.TimeZoneId}\", Zeiten \"{slot.LiquidHours}\")."); + + return schedule; + } + finally + { + _contracts.TryRemove(id, out _); + } + } + /// /// Fragt das Orderbuch des Kontos ab – alle offenen Orders, auch die anderer Client-IDs und die /// aus früheren Sitzungen. @@ -763,8 +807,15 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable public override void contractDetails(int reqId, ContractDetails contractDetails) { - if (_contracts.TryGetValue(reqId, out var slot)) - slot.First ??= contractDetails.Contract; + if (!_contracts.TryGetValue(reqId, out var slot)) return; + + if (slot.First is null) + { + slot.First = contractDetails.Contract; + slot.TradingHours = contractDetails.TradingHours; + slot.LiquidHours = contractDetails.LiquidHours; + slot.TimeZoneId = contractDetails.TimeZoneId; + } } public override void contractDetailsEnd(int reqId) @@ -1119,6 +1170,12 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable private sealed class ContractSlot : Slot { public Contract? First; + + // Handelszeiten kommen im selben Callback wie der Kontrakt. Sie hier mitzunehmen kostet + // nichts; eine eigene Abfrage dafuer waere eine zweite Runde ueber den Socket. + public string? TradingHours; + public string? LiquidHours; + public string? TimeZoneId; } private sealed class OptionChainSlot : Slot diff --git a/src/IBKRTrader.Core/Trading/MarketHours.cs b/src/IBKRTrader.Core/Trading/MarketHours.cs new file mode 100644 index 0000000..3d539d7 --- /dev/null +++ b/src/IBKRTrader.Core/Trading/MarketHours.cs @@ -0,0 +1,187 @@ +using System.Globalization; + +namespace IBKRTrader.Core.Trading; + +/// Ein zusammenhängender Zeitraum, in dem an einer Börse gehandelt wird – immer UTC. +public sealed record MarketSession(DateTime OpenUtc, DateTime CloseUtc) +{ + public bool Contains(DateTime utc) => utc >= OpenUtc && utc < CloseUtc; +} + +/// +/// Die Handelszeiten eines Instruments, wie der Broker sie meldet. +/// +/// Warum das aus dem Broker kommt und nicht aus einer Tabelle im Code. Feiertage, +/// halbe Handelstage, Sommerzeitwechsel und Sonderregeln unterscheiden sich je Börse und ändern +/// sich jedes Jahr. Eine gepflegte Liste wäre an dem Tag falsch, an dem sie niemand pflegt – und +/// das merkt man erst, wenn Orders ins Leere laufen. IBKR weiß es ohnehin. +/// +/// Zeitzone der Börse laut Broker, nur zur Nachvollziehbarkeit. +/// Die gemeldeten Handelszeiträume, aufsteigend. +public sealed record MarketSchedule(string TimeZoneId, IReadOnlyList Sessions) +{ + /// Wird zu diesem Zeitpunkt gehandelt? + public bool IsOpen(DateTime utc) => Sessions.Any(s => s.Contains(utc)); + + /// + /// Nächster Handelsbeginn ab ; null, wenn der gemeldete Zeitraum + /// keinen mehr enthält. Der Broker liefert nur wenige Tage im Voraus – „kein nächster Beginn" + /// heißt deshalb nicht „nie wieder". + /// + public DateTime? NextOpenUtc(DateTime utc) => + Sessions.Where(s => s.OpenUtc > utc) + .Select(s => (DateTime?)s.OpenUtc) + .DefaultIfEmpty(null) + .Min(); +} + +/// +/// Liest die Handelszeiten-Zeichenketten der TWS-API. +/// +/// Reine Rechnung, damit die Formate ohne Broker prüfbar sind. TWS liefert zwei Schreibweisen, +/// je nach Alter des Kontrakts und der API-Fassung: +/// +/// 20260907:0900-20260907:1730;20260908:CLOSED – Datum auf beiden Seiten +/// 20260907:0900-1730,1900-2200;20260908:CLOSED – nur Uhrzeiten, Datum vom Segment +/// +/// Beide werden gelesen. Was sich nicht lesen lässt, wird übersprungen und nicht +/// geraten: Ein falsch gedeuteter Zeitraum ist schlimmer als ein fehlender, weil er Handel zu einer +/// Zeit erlaubt, zu der die Börse zu ist. +/// +public static class TradingHours +{ + /// + /// Liest die Zeichenkette. null, wenn die Zeitzone unbekannt ist oder nichts Lesbares + /// übrig bleibt – der Aufrufer muss diesen Fall als „unbekannt" behandeln, nicht als „offen". + /// + public static MarketSchedule? Parse(string? hours, string? timeZoneId) + { + if (string.IsNullOrWhiteSpace(hours)) return null; + + var zone = ResolveZone(timeZoneId); + if (zone is null) return null; + + var sessions = new List(); + + foreach (var segment in hours.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var colon = segment.IndexOf(':'); + if (colon <= 0) continue; + + var dayText = segment[..colon].Trim(); + var body = segment[(colon + 1)..].Trim(); + + if (body.Equals("CLOSED", StringComparison.OrdinalIgnoreCase)) continue; + if (!TryDay(dayText, out var day)) continue; + + foreach (var range in body.Split(',', StringSplitOptions.RemoveEmptyEntries)) + { + if (TryRange(range, day, zone, out var session)) sessions.Add(session); + } + } + + if (sessions.Count == 0) return null; + + sessions.Sort((a, b) => a.OpenUtc.CompareTo(b.OpenUtc)); + return new MarketSchedule(zone.Id, sessions); + } + + /// + /// Sucht die Zeitzone des Brokers. TWS meldet IANA-Namen ("US/Eastern", "Europe/Berlin") und + /// gelegentlich Kürzel ("MET", "GMT"). Findet sich keine, gibt es keine Ersatzzone: + /// Mit der falschen Zone gerechnete Handelszeiten sind schlimmer als gar keine. + /// + private static TimeZoneInfo? ResolveZone(string? id) + { + if (string.IsNullOrWhiteSpace(id)) return null; + + try { return TimeZoneInfo.FindSystemTimeZoneById(id.Trim()); } + catch (TimeZoneNotFoundException) { } + catch (InvalidTimeZoneException) { } + + // Ein paar Kuerzel, die TWS verwendet und die als Zonen-Id nicht ueberall existieren. + var fallback = id.Trim().ToUpperInvariant() switch + { + "MET" => "Europe/Berlin", + "CET" => "Europe/Berlin", + "EST5EDT" => "America/New_York", + "CST6CDT" => "America/Chicago", + "MST7MDT" => "America/Denver", + "PST8PDT" => "America/Los_Angeles", + "JST" => "Asia/Tokyo", + "GMT" or "UTC" => "UTC", + _ => null + }; + + if (fallback is null) return null; + + try { return TimeZoneInfo.FindSystemTimeZoneById(fallback); } + catch { return null; } + } + + private static bool TryDay(string text, out DateTime day) => + DateTime.TryParseExact(text, "yyyyMMdd", CultureInfo.InvariantCulture, + DateTimeStyles.None, out day); + + private static bool TryRange(string range, DateTime day, TimeZoneInfo zone, out MarketSession session) + { + session = null!; + + var dash = range.IndexOf('-'); + if (dash <= 0) return false; + + if (!TryPoint(range[..dash], day, zone, out var open)) return false; + if (!TryPoint(range[(dash + 1)..], day, zone, out var close)) return false; + + // Ein Zeitraum ueber Mitternacht (etwa Futures) meldet TWS in der kurzen Schreibweise ohne + // Datum. Ohne diese Korrektur laege das Ende vor dem Anfang und der Zeitraum fiele weg. + if (close <= open) close = close.AddDays(1); + + session = new MarketSession(open, close); + return true; + } + + /// Liest "HHmm" (Datum aus dem Segment) oder "yyyyMMdd:HHmm" und rechnet nach UTC. + private static bool TryPoint(string text, DateTime day, TimeZoneInfo zone, out DateTime utc) + { + utc = default; + var value = text.Trim(); + + var colon = value.IndexOf(':'); + if (colon > 0) + { + if (!TryDay(value[..colon], out day)) return false; + value = value[(colon + 1)..].Trim(); + } + + if (!DateTime.TryParseExact(value, "HHmm", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var time)) + return false; + + var local = DateTime.SpecifyKind(day.Date.Add(time.TimeOfDay), DateTimeKind.Unspecified); + + // Waehrend der Zeitumstellung kann ein lokaler Zeitpunkt doppelt oder gar nicht existieren. + // TimeZoneInfo wirft dann nicht, sondern rechnet - ausser bei "gibt es nicht", deshalb der + // Fang. Ein uebersprungener Zeitraum ist die sichere Richtung. + try { utc = TimeZoneInfo.ConvertTimeToUtc(local, zone); return true; } + catch (ArgumentException) { return false; } + } +} + +/// +/// Auskunft über die Handelszeiten eines Instruments. +/// +/// Eigener Seam neben : Wer wissen will, ob eine Börse offen ist, +/// braucht dafür nicht die Fähigkeit, Orders auszulösen (Kapitalmodell §2 Regel 4) – dieselbe +/// Trennung wie bei . +/// +public interface IMarketCalendar +{ + /// + /// Handelszeiten des Instruments; null, wenn sie sich nicht ermitteln lassen. + /// + /// „Unbekannt" ist nicht „offen". Ein Aufrufer, der aus null auf einen + /// offenen Markt schließt, handelt bei jeder Verbindungsstörung ins Geschlossene. + /// + Task GetScheduleAsync(InstrumentRef instrument, CancellationToken ct = default); +} diff --git a/src/IBKRTrader.Core/Trading/NullBrokerClient.cs b/src/IBKRTrader.Core/Trading/NullBrokerClient.cs index 2c48557..08f3266 100644 --- a/src/IBKRTrader.Core/Trading/NullBrokerClient.cs +++ b/src/IBKRTrader.Core/Trading/NullBrokerClient.cs @@ -8,7 +8,8 @@ namespace IBKRTrader.Core.Trading; /// Paper-Gateway verifiziert ist. So kann keine Order versehentlich rausgehen. /// public sealed class NullBrokerClient - : IBrokerClient, IBrokerPortfolioReader, IBrokerOrderBook, IOptionMarketData, IQuoteSource + : IBrokerClient, IBrokerPortfolioReader, IBrokerOrderBook, IOptionMarketData, IQuoteSource, + IMarketCalendar { private readonly LoggingService _logger; @@ -28,6 +29,11 @@ public sealed class NullBrokerClient public Task CancelOrderAsync(int orderId, CancellationToken ct = default) => Task.FromResult(false); + // Ohne Broker sind die Handelszeiten unbekannt - ausdruecklich nicht "geschlossen" und erst + // recht nicht "offen". Wer fragt, muss den Fall selbst entscheiden. + public Task GetScheduleAsync(InstrumentRef instrument, CancellationToken ct = default) + => Task.FromResult(null); + public Task GetQuoteAsync(InstrumentRef instrument, CancellationToken ct = default) => Task.FromResult(null); diff --git a/src/IBKRTrader.Hosting/AppHostBuilder.cs b/src/IBKRTrader.Hosting/AppHostBuilder.cs index dd03c89..37931ac 100644 --- a/src/IBKRTrader.Hosting/AppHostBuilder.cs +++ b/src/IBKRTrader.Hosting/AppHostBuilder.cs @@ -282,6 +282,7 @@ public static class AppHostBuilder services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); } @@ -291,6 +292,7 @@ public static class AppHostBuilder services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); } diff --git a/src/IBKRTrader.Modules.DebugTrading/DebugTradingModule.cs b/src/IBKRTrader.Modules.DebugTrading/DebugTradingModule.cs index 0f57410..6dd1995 100644 --- a/src/IBKRTrader.Modules.DebugTrading/DebugTradingModule.cs +++ b/src/IBKRTrader.Modules.DebugTrading/DebugTradingModule.cs @@ -61,6 +61,7 @@ public sealed class DebugTradingModule : IModule execution?.Create(sp) ?? throw new InvalidOperationException( "DebugTrading braucht den gebundenen Ausführungszugang des Hosts."), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService())); services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/src/IBKRTrader.Modules.DebugTrading/Logic/DebugTradingPlan.cs b/src/IBKRTrader.Modules.DebugTrading/Logic/DebugTradingPlan.cs index 0226dbb..ae4b1b6 100644 --- a/src/IBKRTrader.Modules.DebugTrading/Logic/DebugTradingPlan.cs +++ b/src/IBKRTrader.Modules.DebugTrading/Logic/DebugTradingPlan.cs @@ -41,25 +41,37 @@ public static class DebugTradingPlan /// Stückzahl ohne Preis wäre geraten. /// /// Zufallsquelle; in Tests fest vorgegeben. + /// + /// Ist an diesem Instrument gerade Handel möglich? null heißt „ohne Prüfung". + /// + /// Gilt für beide Richtungen, auch fürs Schließen. Bei geschlossener Börse nimmt + /// IBKR eine Order an und merkt sie für die nächste Eröffnung vor – eine Schließung erzeugte + /// dieselben wartenden Orders wie eine Eröffnung. Eine Testposition ein paar Stunden länger zu + /// halten kostet dagegen nichts. + /// public static IReadOnlyList Plan( IReadOnlyList open, DateTime nowUtc, decimal spentToday, DebugTradingSettings settings, Func priceOf, - Random random) + Random random, + Func? isTradable = null) { - var actions = new List(); + var actions = new List(); + var tradable = isTradable ?? (_ => true); // 1. Fällige Positionen schließen - immer und unabhängig von allen Grenzen. - foreach (var position in open.Where(p => p.CloseAtUtc <= nowUtc)) + foreach (var position in open.Where(p => p.CloseAtUtc <= nowUtc && tradable(p.Instrument))) actions.Add(new DebugAction.Close(position, $"Haltedauer abgelaufen ({position.CloseAtUtc:yyyy-MM-dd HH:mm} UTC).")); // 2. Eröffnen nur, wenn alle Grenzen es zulassen. if (settings.StopOpening) return actions; - var stillOpen = open.Count - actions.Count; + // Gezaehlt werden geschlossene Positionen, nicht geplante Handlungen: Eine faellige + // Position, die wegen geschlossener Boerse NICHT geschlossen wird, belegt ihren Platz weiter. + var stillOpen = open.Count - actions.OfType().Count(); if (stillOpen >= settings.MaxOpenPositions) return actions; if (spentToday >= (decimal)settings.DailyBudget) return actions; @@ -69,7 +81,7 @@ public static class DebugTradingPlan // Reihenfolge würfeln und den ersten nehmen, für den es einen brauchbaren Kurs gibt. // Ein fehlender Kurs ist der Normalfall (geschlossene Börse, fehlendes Abo) und darf den // Lauf nicht leer ausgehen lassen, solange ein anderer Wert handelbar ist. - foreach (var instrument in universe.OrderBy(_ => random.Next())) + foreach (var instrument in universe.Where(tradable).OrderBy(_ => random.Next())) { if (priceOf(instrument) is not { } price || price <= 0m) continue; diff --git a/src/IBKRTrader.Modules.DebugTrading/Workers/DebugTradingWorker.cs b/src/IBKRTrader.Modules.DebugTrading/Workers/DebugTradingWorker.cs index 33c2b19..c02b503 100644 --- a/src/IBKRTrader.Modules.DebugTrading/Workers/DebugTradingWorker.cs +++ b/src/IBKRTrader.Modules.DebugTrading/Workers/DebugTradingWorker.cs @@ -26,6 +26,7 @@ public class DebugTradingWorker : WorkerBase { private readonly IModuleExecution _execution; private readonly IQuoteSource _quotes; + private readonly IMarketCalendar _calendar; private readonly SettingsService _settings; private readonly IDbContextFactory _own; private readonly Random _random = new(); @@ -44,12 +45,14 @@ public class DebugTradingWorker : WorkerBase IDbContextFactory own, IModuleExecution execution, IQuoteSource quotes, + IMarketCalendar calendar, SettingsService settings) : base(logger, coreDb) { _own = own; _execution = execution; _quotes = quotes; + _calendar = calendar; _settings = settings; Info.Active = settings.Settings.DebugTrading.Enabled; } @@ -68,11 +71,18 @@ public class DebugTradingWorker : WorkerBase var spentToday = await SpentTodayAsync(ct); var quotes = new Dictionary(StringComparer.Ordinal); + // Handelbarkeit VOR der Planung: Bei geschlossener Börse nimmt IBKR eine Order an und merkt + // sie für die nächste Eröffnung vor. Ein Modul im 15-Minuten-Takt sammelt über ein Wochenende + // Dutzende davon an, die dann montags alle auf einmal auslösen. Am 2026-09-06 mit 31 offenen + // Orders genau so beobachtet. + var tradable = await TradableAsync(open, ct); + var actions = DebugTradingPlan.Plan( [.. open.Select(ToPlanned)], DateTime.UtcNow, spentToday, Config, instrument => QuoteOf(instrument, quotes, ct), - _random); + _random, + tradable); if (actions.Count == 0) { @@ -220,6 +230,57 @@ public class DebugTradingWorker : WorkerBase return true; } + // ─── Handelszeiten ────────────────────────────────────────────────────── + + /// + /// Baut die Auskunft, an welchen Instrumenten gerade gehandelt werden kann – einmal je Lauf, + /// für das Universum und die offenen Positionen zusammen. + /// + /// Unbekannt gilt als geschlossen. Wer aus einer fehlenden Auskunft auf einen + /// offenen Markt schlösse, handelte bei jeder Verbindungsstörung genau ins Geschlossene, das + /// hier vermieden werden soll. Ein Prüfmodul, das einen Lauf auslässt, kostet nichts. + /// + private async Task?> TradableAsync( + IReadOnlyList open, CancellationToken ct) + { + if (!Config.OnlyDuringMarketHours) return null; + + var instruments = DebugTradingPlan.ParseUniverse(Config.Universe) + .Concat(open.Select(o => ToPlanned(o).Instrument)) + .GroupBy(i => i.Key, StringComparer.Ordinal) + .Select(g => g.First()) + .ToList(); + + var now = DateTime.UtcNow; + var openNow = new HashSet(StringComparer.Ordinal); + var nextOpen = new List<(string Key, DateTime At)>(); + + foreach (var instrument in instruments) + { + ct.ThrowIfCancellationRequested(); + + var schedule = await _calendar.GetScheduleAsync(instrument, ct); + if (schedule is null) continue; // keine Auskunft = geschlossen + + if (schedule.IsOpen(now)) openNow.Add(instrument.Key); + else if (schedule.NextOpenUtc(now) is { } at) nextOpen.Add((instrument.Key, at)); + } + + if (openNow.Count == 0) + { + // Eine Zeile je Lauf, und sie sagt, wann es weitergeht - sonst sieht ein stiller + // Wochenendlauf aus wie ein kaputtes Modul. + var soonest = nextOpen.OrderBy(x => x.At).FirstOrDefault(); + Info.Info = soonest.Key is null + ? "Kein Instrument handelbar (keine Auskunft über Handelszeiten)." + : $"Kein Instrument handelbar, nächste Eröffnung {soonest.Key} um " + + $"{soonest.At:yyyy-MM-dd HH:mm} UTC."; + Logger.Info(Module, Info.Info); + } + + return instrument => openNow.Contains(instrument.Key); + } + // ─── Beschaffung ───────────────────────────────────────────────────────────── private async Task> LoadOpenAsync(CancellationToken ct) diff --git a/tests/IBKRTrader.Tests/Modules/DebugTrading/DebugTradingLogicTests.cs b/tests/IBKRTrader.Tests/Modules/DebugTrading/DebugTradingLogicTests.cs index cd11048..47a53fb 100644 --- a/tests/IBKRTrader.Tests/Modules/DebugTrading/DebugTradingLogicTests.cs +++ b/tests/IBKRTrader.Tests/Modules/DebugTrading/DebugTradingLogicTests.cs @@ -186,4 +186,85 @@ public class DebugTradingLogicTests parsed.Should().ContainSingle().Which.Key.Should().Be("SAP:EUR"); } + + // ─── Geschlossene Börse (#502) ────────────────────────────────────── + + private static readonly Func NichtsHandelbar = _ => false; + + /// + /// 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. + /// + [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().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(); + } } diff --git a/tests/IBKRTrader.Tests/Trading/TradingHoursTests.cs b/tests/IBKRTrader.Tests/Trading/TradingHoursTests.cs new file mode 100644 index 0000000..d6face5 --- /dev/null +++ b/tests/IBKRTrader.Tests/Trading/TradingHoursTests.cs @@ -0,0 +1,156 @@ +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(); + } +}