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>
This commit is contained in:
@@ -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.")]
|
||||
|
||||
@@ -16,7 +16,8 @@ namespace IBKRTrader.Core.Trading.Ibkr;
|
||||
/// die sichere Richtung, wenn der Broker nicht erreichbar ist.
|
||||
/// </summary>
|
||||
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<MarketSchedule?> 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<IReadOnlyList<BrokerOrder>> GetOpenOrdersAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Array.Empty<BrokerOrder>();
|
||||
|
||||
@@ -669,6 +669,50 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handelszeiten eines Instruments, aus derselben Kontraktabfrage wie der Kontrakt selbst.
|
||||
///
|
||||
/// <para><b>LiquidHours und nicht TradingHours.</b> TradingHours enthaelt bei US-Werten auch
|
||||
/// die vor- und nachboersliche Zeit. Unsere Orders setzen <c>OutsideRth</c> 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.</para>
|
||||
/// </summary>
|
||||
public async Task<MarketSchedule?> 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 _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace IBKRTrader.Core.Trading;
|
||||
|
||||
/// <summary>Ein zusammenhängender Zeitraum, in dem an einer Börse gehandelt wird – immer UTC.</summary>
|
||||
public sealed record MarketSession(DateTime OpenUtc, DateTime CloseUtc)
|
||||
{
|
||||
public bool Contains(DateTime utc) => utc >= OpenUtc && utc < CloseUtc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Handelszeiten eines Instruments, wie der Broker sie meldet.
|
||||
///
|
||||
/// <para><b>Warum das aus dem Broker kommt und nicht aus einer Tabelle im Code.</b> 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.</para>
|
||||
/// </summary>
|
||||
/// <param name="TimeZoneId">Zeitzone der Börse laut Broker, nur zur Nachvollziehbarkeit.</param>
|
||||
/// <param name="Sessions">Die gemeldeten Handelszeiträume, aufsteigend.</param>
|
||||
public sealed record MarketSchedule(string TimeZoneId, IReadOnlyList<MarketSession> Sessions)
|
||||
{
|
||||
/// <summary>Wird zu diesem Zeitpunkt gehandelt?</summary>
|
||||
public bool IsOpen(DateTime utc) => Sessions.Any(s => s.Contains(utc));
|
||||
|
||||
/// <summary>
|
||||
/// Nächster Handelsbeginn ab <paramref name="utc"/>; <c>null</c>, wenn der gemeldete Zeitraum
|
||||
/// keinen mehr enthält. Der Broker liefert nur wenige Tage im Voraus – „kein nächster Beginn"
|
||||
/// heißt deshalb <b>nicht</b> „nie wieder".
|
||||
/// </summary>
|
||||
public DateTime? NextOpenUtc(DateTime utc) =>
|
||||
Sessions.Where(s => s.OpenUtc > utc)
|
||||
.Select(s => (DateTime?)s.OpenUtc)
|
||||
.DefaultIfEmpty(null)
|
||||
.Min();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest die Handelszeiten-Zeichenketten der TWS-API.
|
||||
///
|
||||
/// <para>Reine Rechnung, damit die Formate ohne Broker prüfbar sind. TWS liefert zwei Schreibweisen,
|
||||
/// je nach Alter des Kontrakts und der API-Fassung:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>20260907:0900-20260907:1730;20260908:CLOSED</c> – Datum auf beiden Seiten</item>
|
||||
/// <item><c>20260907:0900-1730,1900-2200;20260908:CLOSED</c> – nur Uhrzeiten, Datum vom Segment</item>
|
||||
/// </list>
|
||||
/// <para>Beide werden gelesen. Was sich nicht lesen lässt, wird <b>übersprungen</b> 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.</para>
|
||||
/// </summary>
|
||||
public static class TradingHours
|
||||
{
|
||||
/// <summary>
|
||||
/// Liest die Zeichenkette. <c>null</c>, wenn die Zeitzone unbekannt ist oder nichts Lesbares
|
||||
/// übrig bleibt – der Aufrufer muss diesen Fall als „unbekannt" behandeln, nicht als „offen".
|
||||
/// </summary>
|
||||
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<MarketSession>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sucht die Zeitzone des Brokers. TWS meldet IANA-Namen ("US/Eastern", "Europe/Berlin") und
|
||||
/// gelegentlich Kürzel ("MET", "GMT"). Findet sich keine, gibt es <b>keine</b> Ersatzzone:
|
||||
/// Mit der falschen Zone gerechnete Handelszeiten sind schlimmer als gar keine.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Liest "HHmm" (Datum aus dem Segment) oder "yyyyMMdd:HHmm" und rechnet nach UTC.</summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auskunft über die Handelszeiten eines Instruments.
|
||||
///
|
||||
/// <para>Eigener Seam neben <see cref="IBrokerClient"/>: 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 <see cref="IQuoteSource"/>.</para>
|
||||
/// </summary>
|
||||
public interface IMarketCalendar
|
||||
{
|
||||
/// <summary>
|
||||
/// Handelszeiten des Instruments; <c>null</c>, wenn sie sich nicht ermitteln lassen.
|
||||
///
|
||||
/// <para><b>„Unbekannt" ist nicht „offen".</b> Ein Aufrufer, der aus <c>null</c> auf einen
|
||||
/// offenen Markt schließt, handelt bei jeder Verbindungsstörung ins Geschlossene.</para>
|
||||
/// </summary>
|
||||
Task<MarketSchedule?> GetScheduleAsync(InstrumentRef instrument, CancellationToken ct = default);
|
||||
}
|
||||
@@ -8,7 +8,8 @@ namespace IBKRTrader.Core.Trading;
|
||||
/// Paper-Gateway verifiziert ist. So kann keine Order versehentlich rausgehen.
|
||||
/// </summary>
|
||||
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<bool> 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<MarketSchedule?> GetScheduleAsync(InstrumentRef instrument, CancellationToken ct = default)
|
||||
=> Task.FromResult<MarketSchedule?>(null);
|
||||
|
||||
public Task<Quote?> GetQuoteAsync(InstrumentRef instrument, CancellationToken ct = default)
|
||||
=> Task.FromResult<Quote?>(null);
|
||||
|
||||
|
||||
@@ -282,6 +282,7 @@ public static class AppHostBuilder
|
||||
services.AddSingleton<IBrokerClient>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
||||
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
||||
services.AddSingleton<IBrokerOrderBook>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
||||
services.AddSingleton<IMarketCalendar>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
||||
services.AddSingleton<IOptionMarketData>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
||||
services.AddSingleton<IQuoteSource>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
||||
}
|
||||
@@ -291,6 +292,7 @@ public static class AppHostBuilder
|
||||
services.AddSingleton<IBrokerClient>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||
services.AddSingleton<IBrokerOrderBook>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||
services.AddSingleton<IMarketCalendar>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||
services.AddSingleton<IOptionMarketData>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||
services.AddSingleton<IQuoteSource>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||
}
|
||||
|
||||
@@ -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<IQuoteSource>(),
|
||||
sp.GetRequiredService<IMarketCalendar>(),
|
||||
sp.GetRequiredService<SettingsService>()));
|
||||
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<DebugTradingWorker>());
|
||||
|
||||
@@ -41,25 +41,37 @@ public static class DebugTradingPlan
|
||||
/// Stückzahl ohne Preis wäre geraten.
|
||||
/// </param>
|
||||
/// <param name="random">Zufallsquelle; in Tests fest vorgegeben.</param>
|
||||
/// <param name="isTradable">
|
||||
/// Ist an diesem Instrument gerade Handel möglich? <c>null</c> heißt „ohne Prüfung".
|
||||
///
|
||||
/// <para>Gilt für <b>beide</b> 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.</para>
|
||||
/// </param>
|
||||
public static IReadOnlyList<DebugAction> Plan(
|
||||
IReadOnlyList<OpenTestPosition> open,
|
||||
DateTime nowUtc,
|
||||
decimal spentToday,
|
||||
DebugTradingSettings settings,
|
||||
Func<InstrumentRef, decimal?> priceOf,
|
||||
Random random)
|
||||
Random random,
|
||||
Func<InstrumentRef, bool>? isTradable = null)
|
||||
{
|
||||
var actions = new List<DebugAction>();
|
||||
var actions = new List<DebugAction>();
|
||||
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<DebugAction.Close>().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;
|
||||
|
||||
|
||||
@@ -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<DebugTradingDbContext> _own;
|
||||
private readonly Random _random = new();
|
||||
@@ -44,12 +45,14 @@ public class DebugTradingWorker : WorkerBase
|
||||
IDbContextFactory<DebugTradingDbContext> 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<string, decimal?>(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 ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Baut die Auskunft, an welchen Instrumenten gerade gehandelt werden kann – einmal je Lauf,
|
||||
/// für das Universum und die offenen Positionen zusammen.
|
||||
///
|
||||
/// <para><b>Unbekannt gilt als geschlossen.</b> 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.</para>
|
||||
/// </summary>
|
||||
private async Task<Func<InstrumentRef, bool>?> TradableAsync(
|
||||
IReadOnlyList<DebugPosition> 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<string>(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<IReadOnlyList<DebugPosition>> LoadOpenAsync(CancellationToken ct)
|
||||
|
||||
@@ -186,4 +186,85 @@ public class DebugTradingLogicTests
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user