4 Commits
Author SHA1 Message Date
Richard 6189d1bf8c feat: Darkmode, Core/Modul-Gliederung, OptionsWheel & DebugTrading Web-UI, Supervisor entfernt
Build & Test / build (ubuntu-latest) (push) Canceled after 0s
Build & Test / build (windows-latest) (push) Canceled after 0s
2026-09-08 12:21:26 +02:00
RichardandClaude Opus 5 2ca5bc6553 Reconnect laesst keinen Lesethread mehr zurueck (#504, #505)
Nach jedem Verbindungsaufbau lief ein weiterer IBKR-Lesethread mit: Die
Schleifenbedingung "while (_socket.IsConnected())" ist nach einem Reconnect
wieder wahr, weil dasselbe Socket-Objekt neu verbunden wird. Auf der Test-VM
waren es nach zwei TWS-Neustarts drei Threads gleichzeitig.

Alle warten auf demselben EReaderMonitorSignal, und ein Signal weckt genau
einen. Weckt es den falschen, bleibt die Nachricht in der Warteschlange eines
anderen Readers liegen. Bei laufend wiederholten Push-Daten faellt das nicht
auf, die einmalige Antwort auf reqPositions laeuft in die Frist - der Dienst
sieht gesund aus und ist blind. Zeitueberschreitungen je Tag: 1, 3, 215, 96.

Jeder Lesethread bekommt jetzt eine Generationsnummer. Veraltete beenden sich
beim naechsten Aufwachen und reichen das verbrauchte Signal weiter, damit die
Nachricht des Nachfolgers nicht liegen bleibt.

Dazu ein Zwischenspeicher fuer die Handelszeiten (#505): dieselbe Auskunft
wurde viermal je Stunde und Instrument neu angefragt, obwohl sie sich
hoechstens taeglich aendert. Gueltig bis zum Ende des gemeldeten Zeitraums;
Fehlschlaege werden bewusst nicht gemerkt, sonst bliebe ein Modul nach einer
einzigen Stoerung bis zum Neustart stumm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:43:09 +02:00
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
RichardandClaude Opus 5 7b16474a33 Nachbesserungen am Web-UI: Optionsschluessel, Erreichbarkeit, Vorgaben
Vier Punkte aus der Durchsicht von f923721. Alles Uebrige traegt: Anmeldung
greift (unangemeldet 401 auf jedem /api-Pfad ausser status und login), alle
zwanzig Endpunkte antworten, SSE laeuft, die Oberflaeche zeigt echte Zahlen,
die Daemon-Schalter funktionieren weiter, und die Modul-Routen sitzen richtig
im Hosting-Projekt statt im Core.

1. Die Endpunkte /positions/lock und /positions/reassign deuteten den
   Instrumentenschluessel mit InstrumentRef.ForStock als Ticker. Bei einer Aktie
   ging das zufaellig gut - die Waehrung fiel still auf USD, der Schluessel kam
   trotzdem richtig heraus. Bei einem Optionskontrakt warf Normalize wegen des
   Trennzeichens "|" eine Ausnahme: HTTP 500 ohne Text, auf der Testmaschine
   reproduziert. Jetzt InstrumentRef.TryParse, und ein unlesbarer Schluessel
   ebenso wie ein unsinniger Buchname ergibt eine Antwort statt eines Absturzes.

2. OrderManager las UseTwsApi als Erreichbarkeitsnachweis. Das ist eine
   Einstellung, kein Zustand - sie steht auch dann auf true, wenn TWS nicht
   laeuft. Die Ansicht haette waehrend jeder Stoerung "Broker erreichbar, keine
   offenen Orders" gemeldet und jede Bindung als grundlos dargestellt. Genau
   davor warnt die Beschreibung an IBrokerOrderBook.GetOpenOrdersAsync.

3. AllowRemoteAccess stand als Vorgabe auf true: erster Start heisst 0.0.0.0,
   Klartext-HTTP, und der AuthToken ist anfangs leer. Ausnutzbar war das nicht
   (ohne Token faellt die Anmeldung zu, alles antwortet 401), aber die Vorgabe
   zeigte in die falsche Richtung. Jetzt lokal, mit Begruendung im Text.

4. Das Sitzungstoken stand im Query-String des SSE-Stroms. Unnoetig: EventSource
   schickt das HttpOnly-Cookie bei gleichem Ursprung von selbst. Die Oberflaeche
   nutzt den Weg nicht mehr, und AuthService nimmt ihn nur noch fuer
   /api/logs/stream an - ein Query-String landet in Logs, Verlauf und Referer.

Geminis Test auf den Query-Token bestand auf dem alten, breiten Verhalten. Er
ist durch zwei ersetzt, die die enge Regel in beide Richtungen festhalten.

499 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:17:02 +02:00
32 changed files with 2165 additions and 506 deletions
+7 -3
View File
@@ -75,9 +75,13 @@ public sealed class OrderManager
try
{
brokerOrders = await _orderBook.GetOpenOrdersAsync(ct).ConfigureAwait(false);
// Wenn der Adapter eine Antwort liefert (auch leer), aber keine Exception wirft,
// prüfen wir, ob die Broker-Verbindung grundsätzlich aktiv ist.
reachable = brokerOrders.Count > 0 || _settings.Settings.IBKR.UseTwsApi;
// UseTwsApi ist eine EINSTELLUNG, kein Zustand: Sie steht auch dann auf true, wenn TWS
// gerade nicht laeuft. Als Erreichbarkeitsnachweis gelesen, meldete die Ansicht waehrend
// jeder Stoerung "Broker erreichbar, keine offenen Orders" - und jede Bindung erschiene
// grundlos. Maßgeblich ist allein, dass tatsaechlich etwas kam; siehe die Warnung an
// IBrokerOrderBook.GetOpenOrdersAsync.
reachable = brokerOrders.Count > 0;
}
catch (Exception ex)
{
+11 -2
View File
@@ -163,8 +163,9 @@ public class WebserverSettings
[Category("Webserver")]
[DisplayName("Netzwerkzugriff erlauben")]
[Description("Wenn aktiviert, lauscht der Server auf allen Schnittstellen (0.0.0.0), sonst nur lokal (127.0.0.1)")]
public bool AllowRemoteAccess { get; set; } = true;
[Description("Wenn aktiviert, lauscht der Server auf allen Schnittstellen (0.0.0.0), sonst nur lokal (127.0.0.1). " +
"Vorgabe ist lokal: Der Server spricht Klartext-HTTP, der Token ginge im Netz mit.")]
public bool AllowRemoteAccess { get; set; }
[Category("Webserver")]
[DisplayName("Auth-Token")]
@@ -346,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.")]
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
@@ -16,7 +17,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";
@@ -25,6 +27,10 @@ public sealed class IbkrBrokerClient
private readonly TimeSpan _requestTimeout;
private readonly TimeSpan _orderTimeout;
// Handelszeiten je Instrument, bis zum Ende des gemeldeten Zeitraums gueltig (#505).
private readonly ConcurrentDictionary<string, MarketSchedule> _schedules =
new(StringComparer.OrdinalIgnoreCase);
public IbkrBrokerClient(SettingsService settings, LoggingService logger)
{
_logger = logger;
@@ -112,6 +118,40 @@ public sealed class IbkrBrokerClient
return await _connection!.RequestExecutionsAsync(since, _requestTimeout, ct).ConfigureAwait(false);
}
/// <summary>
/// Handelszeiten des Instruments, zwischengespeichert bis zum Ende des gemeldeten Zeitraums.
///
/// <para><b>Warum ueberhaupt ein Zwischenspeicher.</b> Ein Modul im 15-Minuten-Takt fragte sonst
/// dieselbe Auskunft viermal je Stunde und Instrument neu ab, obwohl sie sich hoechstens
/// taeglich aendert. Jede dieser Anfragen belegt einen Antwortplatz beim Broker und kann in die
/// Zeitueberschreitung laufen – Aufwand ohne Ertrag.</para>
///
/// <para><b>Fehlschlaege werden nicht gemerkt.</b> Ein <c>null</c> heisst „keine Auskunft" und
/// gilt beim Aufrufer als geschlossen. Wuerde das haengenbleiben, bliebe ein Modul nach einer
/// einzigen Stoerung bis zum Neustart stumm.</para>
/// </summary>
public async Task<MarketSchedule?> GetScheduleAsync(InstrumentRef instrument, CancellationToken ct = default)
{
var key = instrument.Key;
// Gueltig, solange der gemeldete Zeitraum noch in der Zukunft endet. Danach wuerde die
// Auskunft still veralten - und "gestern war offen" ist die gefaehrliche Richtung.
if (_schedules.TryGetValue(key, out var cached) &&
cached.Sessions.Count > 0 &&
cached.Sessions[^1].CloseUtc > DateTime.UtcNow)
{
return cached;
}
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return null;
var schedule = await _connection!.RequestMarketScheduleAsync(instrument, _requestTimeout, ct)
.ConfigureAwait(false);
if (schedule is not null) _schedules[key] = schedule;
return schedule;
}
public async Task<IReadOnlyList<BrokerOrder>> GetOpenOrdersAsync(CancellationToken ct = default)
{
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Array.Empty<BrokerOrder>();
@@ -55,6 +55,9 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
private bool _zoneWarningIssued;
private volatile bool _ready;
private volatile bool _disposed;
// Laufende Nummer des Lesethreads. Jeder Verbindungsaufbau startet einen neuen; die aelteren
// erkennen an dieser Nummer, dass sie nicht mehr zustaendig sind, und beenden sich.
private int _readerGeneration;
private int _nextRequestId = 1000;
private int _nextOrderId = -1;
private string? _account;
@@ -235,29 +238,57 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
}
}
/// <summary>
/// Startet den Lesethread fuer die gerade aufgebaute Verbindung.
///
/// <para><b>Warum hier eine Generationsnummer noetig ist.</b> <see cref="_socket"/> und
/// <see cref="_signal"/> ueberleben den Reconnect – wir verbinden dasselbe Socket-Objekt neu.
/// Ein Lesethread mit der Bedingung <c>while (_socket.IsConnected())</c> wuerde deshalb nach
/// einem Reconnect <b>weiterlaufen</b>, denn die Bedingung ist wieder wahr. Nach zwei Nachtneu-
/// starts der TWS liefen am 2026-09-08 drei Lesethreads gleichzeitig (#504).</para>
///
/// <para>Das ist kein harmloses Leck: Alle warten auf dasselbe
/// <see cref="EReaderMonitorSignal"/>, und ein Signal weckt genau einen. Weckt es den falschen,
/// bleibt die Nachricht in der Warteschlange eines anderen Readers liegen, bis zufaellig das
/// naechste Signal kommt. Was der Broker laufend wiederholt (Konto, Kurse), faellt dadurch nicht
/// auf – die einmalige Antwort auf <c>reqPositions</c> oder <c>reqContractDetails</c> laeuft in
/// die Zeitueberschreitung. Der Dienst sieht dann gesund aus und ist blind.</para>
/// </summary>
private void StartReader()
{
var reader = new EReader(_socket, _signal);
var generation = Interlocked.Increment(ref _readerGeneration);
var reader = new EReader(_socket, _signal);
reader.Start();
new Thread(() =>
{
while (_socket.IsConnected())
while (_socket.IsConnected() && Volatile.Read(ref _readerGeneration) == generation)
{
_signal.waitForSignal();
// Nach dem Aufwachen erneut pruefen: Waehrend wir warteten, kann ein Reconnect
// einen neuen Reader gestartet haben. Dann gehoert diese Nachricht ihm.
if (Volatile.Read(ref _readerGeneration) != generation)
{
// Das verbrauchte Signal weitergeben, sonst bliebe die Nachricht des Nachfolgers
// bis zum naechsten Eintreffen liegen.
_signal.issueSignal();
break;
}
try
{
reader.processMsgs();
}
catch (Exception ex)
{
if (!_disposed)
if (!_disposed && Volatile.Read(ref _readerGeneration) == generation)
_logger.Warn(LogModule, $"Nachrichten-Reader beendet: {ex.Message}");
break;
}
}
})
{ IsBackground = true, Name = "IBKR-Reader" }.Start();
{ IsBackground = true, Name = $"IBKR-Reader-{generation}" }.Start();
}
private void SafeDisconnect()
@@ -669,6 +700,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 +838,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 +1201,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
+187
View File
@@ -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);
+2
View File
@@ -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>());
}
+8 -2
View File
@@ -146,8 +146,14 @@ public sealed class AuthService
if (ValidateSessionToken(cookieToken) || ValidateToken(cookieToken)) return true;
}
// 3. Query-Parameter ?token= (wichtig für SSE EventSource)
if (context.Request.Query.TryGetValue("token", out var queryToken))
// 3. Query-Parameter ?token= – NUR fuer den SSE-Strom.
//
// EventSource kann keine Kopfzeilen setzen; ein Client ohne Cookie haette sonst keinen Weg.
// Ueberall sonst ist der Query-String der falsche Ort fuer ein Geheimnis: Er landet in
// Server-Logs, im Browserverlauf und in Referer-Kopfzeilen. Die eigene Oberflaeche nutzt ihn
// gar nicht mehr - sie laesst das HttpOnly-Cookie mitlaufen.
if (context.Request.Path.StartsWithSegments("/api/logs/stream", StringComparison.OrdinalIgnoreCase) &&
context.Request.Query.TryGetValue("token", out var queryToken))
{
if (ValidateSessionToken(queryToken) || ValidateToken(queryToken)) return true;
}
@@ -18,6 +18,50 @@ namespace IBKRTrader.Hosting.Web.Endpoints;
/// </summary>
public static class CoreEndpoints
{
/// <summary>
/// Liest einen Instrumentenschlüssel, wie die Oberfläche ihn anzeigt – "SAP:EUR" ebenso wie
/// "AAPL|20260918|C|150".
///
/// <para><b>Nicht <c>ForStock</c>.</b> Das deutete die ganze Zeichenkette als Ticker: Bei einer
/// Aktie ging das zufällig gut (die Währung landete still auf USD), bei einem Optionskontrakt
/// warf es wegen des Trennzeichens eine Ausnahme – aus dem Web-UI ein HTTP 500 ohne Text.</para>
/// </summary>
private static bool TryReadInstrument(string? key, out InstrumentRef instrument, out string problem)
{
instrument = default;
if (string.IsNullOrWhiteSpace(key))
{
problem = "Kein Instrument angegeben.";
return false;
}
if (!InstrumentRef.TryParse(key, out instrument))
{
problem = $"\"{key}\" ist kein lesbarer Instrumentenschlüssel.";
return false;
}
problem = "";
return true;
}
/// <summary>Liest einen Buchnamen. <c>BookId.Parse</c> wirft bei Unsinn – hier wird daraus eine Antwort.</summary>
private static bool TryReadBook(string? name, out BookId book, out string problem)
{
book = default;
try
{
book = BookId.Parse(name ?? "");
problem = "";
return true;
}
catch (ArgumentException ex)
{
problem = ex.Message;
return false;
}
}
public static void Map(RouteGroupBuilder api)
{
// ─── Status & Auth ────────────────────────────────────────────────────────
@@ -154,23 +198,27 @@ public static class CoreEndpoints
api.MapPost("/positions/reassign", async (ReassignRequest req, PositionManager positions) =>
{
var instrument = InstrumentRef.ForStock(req.Instrument);
var fromBook = BookId.Parse(req.FromBook);
var toBook = BookId.Parse(req.ToBook);
if (!TryReadInstrument(req.Instrument, out var instrument, out var problem))
return Results.BadRequest(new { success = false, message = problem });
if (!TryReadBook(req.FromBook, out var fromBook, out problem) ||
!TryReadBook(req.ToBook, out var toBook, out problem))
return Results.BadRequest(new { success = false, message = problem });
var result = await positions.ReassignAsync(instrument, fromBook, toBook, req.Reason).ConfigureAwait(false);
var result = await positions.ReassignAsync(instrument, fromBook, toBook, req.Reason ?? "").ConfigureAwait(false);
return Results.Ok(new { success = result.Success, message = result.Message });
});
api.MapPost("/positions/lock", async (SetLockRequest req, PositionManager positions) =>
{
var instrument = InstrumentRef.ForStock(req.Instrument);
var book = BookId.Parse(req.Book);
if (!TryReadInstrument(req.Instrument, out var instrument, out var problem))
return Results.BadRequest(new { success = false, message = problem });
if (!TryReadBook(req.Book, out var book, out problem))
return Results.BadRequest(new { success = false, message = problem });
if (!Enum.TryParse<PositionLock>(req.Level, true, out var level))
return Results.BadRequest(new { success = false, message = "Ungültige Sperrstufe." });
await positions.SetLockAsync(book, instrument, level, req.Note).ConfigureAwait(false);
return Results.Ok(new { success = true, message = $"Sperre für {req.Instrument} gesetzt." });
return Results.Ok(new { success = true, message = $"Sperre für {instrument.Key} gesetzt." });
});
api.MapPost("/positions/rule", async (SetRuleRequest req, PositionManager positions) =>
@@ -1,8 +1,6 @@
using System.Globalization;
using System.Text.Json;
using IBKRTrader.Core.Analytics;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Time;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.Accounting.Logic;
@@ -10,12 +8,15 @@ using IBKRTrader.Modules.Accounting.Persistence;
using IBKRTrader.Modules.Accounting.Services;
using IBKRTrader.Modules.CongressTrading;
using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.Modules.Supervisor.Agent;
using IBKRTrader.Modules.Supervisor.Persistence;
using IBKRTrader.Modules.Supervisor.Services;
using IBKRTrader.Modules.DebugTrading.Logic;
using IBKRTrader.Modules.DebugTrading.Persistence.Ef;
using IBKRTrader.Modules.OptionsWheel;
using IBKRTrader.Modules.OptionsWheel.Persistence.Ef;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.Hosting.Web.Endpoints;
@@ -38,8 +39,11 @@ public static class ModuleEndpoints
if (modules.Any(m => string.Equals(m.Name, "Accounting", StringComparison.OrdinalIgnoreCase)))
MapAccounting(api);
if (modules.Any(m => string.Equals(m.Name, "Supervisor", StringComparison.OrdinalIgnoreCase)))
MapSupervisor(api);
if (modules.Any(m => string.Equals(m.Name, "OptionsWheel", StringComparison.OrdinalIgnoreCase)))
MapOptionsWheel(api, sp);
if (modules.Any(m => string.Equals(m.Name, "DebugTrading", StringComparison.OrdinalIgnoreCase)))
MapDebugTrading(api, sp);
}
// ─── CongressTrading ──────────────────────────────────────────────────────────
@@ -47,13 +51,25 @@ public static class ModuleEndpoints
{
var group = api.MapGroup("/modules/congresstrading");
group.MapGet("/summary", async (CongressRepository repo, IPortfolioService portfolio) =>
group.MapGet("/summary", async (IServiceProvider sp) =>
{
var repo = sp.GetService<CongressRepository>();
var portfolio = sp.GetService<IPortfolioService>();
if (repo == null || portfolio == null)
{
return Results.Ok(new
{
trades = 0,
members = 0,
positions = Array.Empty<object>()
});
}
var trades = await repo.GetTradeCountAsync().ConfigureAwait(false);
var members = await repo.GetMemberCountAsync().ConfigureAwait(false);
var positions = await portfolio.GetPositionsAsync(CongressTradingModule.LogTag).ConfigureAwait(false);
return new
return Results.Ok(new
{
trades,
members,
@@ -64,11 +80,15 @@ public static class ModuleEndpoints
avgPrice = p.AvgPrice,
notional = p.Notional
})
};
});
});
group.MapPost("/scrape", async (WorkerEngine engine) =>
group.MapPost("/scrape", async (IServiceProvider sp) =>
{
var engine = sp.GetService<WorkerEngine>();
if (engine == null)
return Results.BadRequest(new { success = false, message = "Worker-Engine nicht verfügbar." });
try
{
await engine.TriggerWorkerAsync("CT-ScrapeWorker").ConfigureAwait(false);
@@ -86,18 +106,40 @@ public static class ModuleEndpoints
{
var group = api.MapGroup("/modules/accounting");
group.MapGet("/accounts", (ILedgerRepository ledger) =>
group.MapGet("/accounts", (IServiceProvider sp) =>
{
return ledger.DistinctAccounts();
var ledger = sp.GetService<ILedgerRepository>();
return Results.Ok(ledger?.DistinctAccounts() ?? (IReadOnlyList<string>)Array.Empty<string>());
});
group.MapGet("/overview", (
AccountingReportService report,
IServiceProvider sp,
string? account,
string? currency,
DateTime? from,
DateTime? to) =>
{
var report = sp.GetService<AccountingReportService>();
if (report == null)
{
return Results.Ok(new
{
currencyCode = "USD",
currencyNote = "",
statement = new
{
netTradingResult = 0m,
tradeVolume = 0m,
dividends = 0m,
fees = 0m,
closingBalance = 0m,
tradeCount = 0,
entryCount = 0
},
monthly = Array.Empty<object>()
});
}
var fromDate = from ?? DateTime.Today.AddMonths(-1);
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
var curr = string.IsNullOrWhiteSpace(currency) ? "USD" : currency;
@@ -109,7 +151,7 @@ public static class ModuleEndpoints
decimal M(decimal v) => Math.Round(v * view.Factor, 2);
return new
return Results.Ok(new
{
currencyCode = view.Code,
currencyNote = view.Note,
@@ -134,21 +176,24 @@ public static class ModuleEndpoints
netTradingResult = M(m.NetTradingResult),
closingBalance = M(m.ClosingBalance)
})
};
});
});
group.MapGet("/ledger", (
ILedgerRepository ledger,
IServiceProvider sp,
string? account,
DateTime? from,
DateTime? to,
int? limit) =>
{
var ledger = sp.GetService<ILedgerRepository>();
if (ledger == null) return Results.Ok(Array.Empty<object>());
var fromDate = from ?? DateTime.Today.AddMonths(-1);
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
var acc = string.IsNullOrWhiteSpace(account) || account == "(alle)" ? null : account;
return ledger.Query(acc, fromDate, toDate, limit ?? 2000).Select(e => new
return Results.Ok(ledger.Query(acc, fromDate, toDate, limit ?? 2000).Select(e => new
{
timestamp = e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
accountId = e.AccountId,
@@ -162,13 +207,16 @@ public static class ModuleEndpoints
feeBase = e.FeeBase,
netBase = e.NetBase,
transactionId = e.TransactionId
});
}));
});
group.MapGet("/runs", (IIngestRunRepository runs, string? account, int? limit) =>
group.MapGet("/runs", (IServiceProvider sp, string? account, int? limit) =>
{
var runs = sp.GetService<IIngestRunRepository>();
if (runs == null) return Results.Ok(Array.Empty<object>());
var acc = string.IsNullOrWhiteSpace(account) || account == "(alle)" ? null : account;
return runs.GetRecent(acc, limit ?? 100).Select(r => new
return Results.Ok(runs.GetRecent(acc, limit ?? 100).Select(r => new
{
accountId = r.AccountId,
startedAt = r.StartedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
@@ -179,11 +227,15 @@ public static class ModuleEndpoints
success = r.Success,
balanceDeltaBase = r.BalanceDeltaBase,
message = r.Message
});
}));
});
group.MapPost("/ingest", async (AccountingIngestService ingest, bool backfill = false) =>
group.MapPost("/ingest", async (IServiceProvider sp, bool backfill = false) =>
{
var ingest = sp.GetService<AccountingIngestService>();
if (ingest == null)
return Results.BadRequest(new { success = false, message = "Accounting-Dienst nicht verfügbar." });
try
{
await ingest.IngestAllAsync(backfill, CancellationToken.None).ConfigureAwait(false);
@@ -195,8 +247,11 @@ public static class ModuleEndpoints
}
});
group.MapGet("/export/ledger.csv", (ILedgerRepository ledger, string? account, DateTime? from, DateTime? to) =>
group.MapGet("/export/ledger.csv", (IServiceProvider sp, string? account, DateTime? from, DateTime? to) =>
{
var ledger = sp.GetService<ILedgerRepository>();
if (ledger == null) return Results.NotFound();
var fromDate = from ?? DateTime.Today.AddMonths(-1);
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
var acc = string.IsNullOrWhiteSpace(account) || account == "(alle)" ? null : account;
@@ -206,8 +261,11 @@ public static class ModuleEndpoints
return Results.File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", "ledger.csv");
});
group.MapGet("/export/statement.csv", (AccountingReportService report, string? account, DateTime? from, DateTime? to) =>
group.MapGet("/export/statement.csv", (IServiceProvider sp, string? account, DateTime? from, DateTime? to) =>
{
var report = sp.GetService<AccountingReportService>();
if (report == null) return Results.NotFound();
var fromDate = from ?? DateTime.Today.AddMonths(-1);
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
var acc = string.IsNullOrWhiteSpace(account) || account == "(alle)" ? null : account;
@@ -218,13 +276,16 @@ public static class ModuleEndpoints
});
group.MapGet("/export/statement.pdf", (
AccountingReportService report,
ILedgerRepository ledger,
IServiceProvider sp,
string? account,
string? currency,
DateTime? from,
DateTime? to) =>
{
var report = sp.GetService<AccountingReportService>();
var ledger = sp.GetService<ILedgerRepository>();
if (report == null || ledger == null) return Results.NotFound();
var fromDate = from ?? DateTime.Today.AddMonths(-1);
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
var acc = string.IsNullOrWhiteSpace(account) || account == "(alle)" ? null : account;
@@ -240,90 +301,312 @@ public static class ModuleEndpoints
});
}
// ─── Supervisor ───────────────────────────────────────────────────────────────
private static void MapSupervisor(RouteGroupBuilder api)
// ─── OptionsWheel ─────────────────────────────────────────────────────────────
private static void MapOptionsWheel(RouteGroupBuilder api, IServiceProvider sp)
{
var group = api.MapGroup("/modules/supervisor");
var group = api.MapGroup("/modules/optionswheel");
group.MapGet("/profiles", () => SupervisorProfiles.All.Select(p => p.Name));
group.MapGet("/signals", (DossierService dossiers, int? limit) =>
group.MapGet("/status", async (
IConfiguration config,
IServiceProvider requestSp) =>
{
return dossiers.RecentSignals(limit ?? 200);
});
var settings = config.GetSection("OptionsWheel").Get<OptionsWheelSettings>() ?? new OptionsWheelSettings();
var module = sp.GetServices<IModule>().FirstOrDefault(m => string.Equals(m.Name, "OptionsWheel", StringComparison.OrdinalIgnoreCase));
var blocker = module?.GetActivationBlocker(config);
group.MapGet("/dossier/{signalId}", (DossierService dossiers, string signalId) =>
{
var dossier = dossiers.BuildForSignal(signalId);
var markdown = DossierBuilder.ToMarkdown(dossier);
return new { signalId, markdown };
});
int underlyingsCount = 0;
int activeCyclesCount = 0;
int openLegsCount = 0;
group.MapGet("/reports", (ISupervisorReportRepository reports, int? limit) =>
{
return reports.GetRecent(limit ?? 100).Select(r => new
var dbf = requestSp.GetService<IDbContextFactory<OptionsWheelDbContext>>();
if (dbf != null)
{
createdAt = r.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
profile = r.Profile,
model = r.Model,
question = r.Question,
answer = r.Answer,
toolCallCount = r.ToolCallCount,
tokens = $"{r.PromptTokens} / {r.CompletionTokens}"
try
{
await using var db = await dbf.CreateDbContextAsync().ConfigureAwait(false);
underlyingsCount = await db.Underlyings.CountAsync().ConfigureAwait(false);
activeCyclesCount = await db.Cycles.CountAsync(c => c.EndedAt == null).ConfigureAwait(false);
openLegsCount = await db.Legs.CountAsync(l => l.Status == "Open").ConfigureAwait(false);
}
catch { /* DB-Zugriff optional falls Tabellen noch nicht angelegt */ }
}
return Results.Ok(new
{
enabled = settings.Enabled,
noNewPositions = settings.NoNewPositions,
scanIntervalMinutes = settings.ScanIntervalMinutes,
maxBoundCapitalPercent = settings.MaxBoundCapitalPercent,
blocker,
underlyingsCount,
activeCyclesCount,
openLegsCount,
rules = new
{
minDte = settings.Rules.MinDaysToExpiry,
maxDte = settings.Rules.MaxDaysToExpiry,
minDelta = settings.Rules.MinDelta,
maxDelta = settings.Rules.MaxDelta,
minAnnualizedReturnPercent = settings.Rules.MinAnnualizedReturnPercent,
rollProfitTargetPercent = settings.Rules.ProfitTargetPercent,
rollDteThreshold = settings.Rules.RollAtDaysToExpiry
}
});
});
group.MapGet("/info", () =>
group.MapGet("/underlyings", async (IServiceProvider requestSp) =>
{
var keySet = !string.IsNullOrEmpty(OpenRouterClient.DefaultApiKeyProvider());
return new
{
openRouterKeyConfigured = keySet,
timeZone = AppTimeZone.CurrentId,
description = "Supervisor – read-only Analyse und Forensik über alle Module."
};
});
group.MapPost("/ask", async (
SupervisorAskRequest req,
SupervisorAgent agent,
ISupervisorReportRepository reports) =>
{
if (string.IsNullOrWhiteSpace(req.Question))
return Results.BadRequest(new { success = false, message = "Keine Frage angegeben." });
var profile = SupervisorProfiles.ByName(req.Profile ?? "");
var dbf = requestSp.GetService<IDbContextFactory<OptionsWheelDbContext>>();
if (dbf == null) return Results.Ok(Array.Empty<object>());
try
{
var result = await agent.AskAsync(req.Question, profile: profile).ConfigureAwait(false);
await using var db = await dbf.CreateDbContextAsync().ConfigureAwait(false);
var list = await db.Underlyings
.OrderBy(u => u.Symbol)
.Select(u => new
{
u.Id,
u.Symbol,
u.Active,
u.Halted,
u.HaltReason,
createdAt = u.CreatedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
updatedAt = u.UpdatedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)
})
.ToListAsync().ConfigureAwait(false);
return Results.Ok(list);
}
catch
{
return Results.Ok(Array.Empty<object>());
}
});
reports.Insert(new SupervisorReport
{
Profile = profile.Name,
Model = SupervisorAgent.DefaultModel,
Question = req.Question,
Answer = result.Answer,
ToolCallsJson = JsonSerializer.Serialize(result.ToolInvocations.Select(i => new { i.Tool, i.Arguments })),
ToolCallCount = result.ToolInvocations.Count,
PromptTokens = result.PromptTokens,
CompletionTokens = result.CompletionTokens
});
group.MapGet("/cycles", async (IServiceProvider requestSp) =>
{
var dbf = requestSp.GetService<IDbContextFactory<OptionsWheelDbContext>>();
if (dbf == null) return Results.Ok(Array.Empty<object>());
return Results.Ok(new
try
{
await using var db = await dbf.CreateDbContextAsync().ConfigureAwait(false);
var list = await db.Cycles
.OrderByDescending(c => c.StartedAt)
.Take(100)
.Select(c => new
{
c.Id,
c.Symbol,
startedAt = c.StartedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
endedAt = c.EndedAt.HasValue ? c.EndedAt.Value.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) : null,
c.PremiumGross,
c.PremiumNet,
c.AssignedPrice,
c.AssignedShares,
c.RealizedPnl,
c.Outcome
})
.ToListAsync().ConfigureAwait(false);
return Results.Ok(list);
}
catch
{
return Results.Ok(Array.Empty<object>());
}
});
group.MapGet("/legs", async (IServiceProvider requestSp) =>
{
var dbf = requestSp.GetService<IDbContextFactory<OptionsWheelDbContext>>();
if (dbf == null) return Results.Ok(Array.Empty<object>());
try
{
await using var db = await dbf.CreateDbContextAsync().ConfigureAwait(false);
var list = await db.Legs
.OrderByDescending(l => l.OpenedAt)
.Take(100)
.Select(l => new
{
l.Id,
l.CycleId,
l.Symbol,
l.ContractKey,
expiry = l.Expiry.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
l.Strike,
l.Right,
l.Contracts,
l.PremiumOpen,
l.PriceClose,
l.DeltaOpen,
l.IvOpen,
l.DteOpen,
openedAt = l.OpenedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
closedAt = l.ClosedAt.HasValue ? l.ClosedAt.Value.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) : null,
l.Status
})
.ToListAsync().ConfigureAwait(false);
return Results.Ok(list);
}
catch
{
return Results.Ok(Array.Empty<object>());
}
});
group.MapGet("/candidates", async (IServiceProvider requestSp) =>
{
var dbf = requestSp.GetService<IDbContextFactory<OptionsWheelDbContext>>();
if (dbf == null) return Results.Ok(Array.Empty<object>());
try
{
await using var db = await dbf.CreateDbContextAsync().ConfigureAwait(false);
var list = await db.Candidates
.OrderByDescending(c => c.ScannedAt)
.Take(100)
.Select(c => new
{
c.Id,
scannedAt = c.ScannedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
c.Symbol,
c.ContractKey,
expiry = c.Expiry.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
c.Strike,
c.Right,
c.Delta,
c.Iv,
c.Bid,
c.Ask,
c.Premium,
c.AnnualizedReturnPercent,
c.Chosen,
rejection = c.Rejection.ToString(),
c.Note
})
.ToListAsync().ConfigureAwait(false);
return Results.Ok(list);
}
catch
{
return Results.Ok(Array.Empty<object>());
}
});
}
// ─── DebugTrading ─────────────────────────────────────────────────────────────
private static void MapDebugTrading(RouteGroupBuilder api, IServiceProvider sp)
{
var group = api.MapGroup("/modules/debugtrading");
group.MapGet("/status", async (IServiceProvider requestSp) =>
{
var settingsService = requestSp.GetService<SettingsService>() ?? new SettingsService();
var settings = settingsService.Settings.DebugTrading;
var trading = settingsService.Settings.Trading;
var blockedReason = DebugTradingGuard.CheckPaperMode(trading.ParsedMode, trading.Mode);
int openPositionsCount = 0;
var dbf = requestSp.GetService<IDbContextFactory<DebugTradingDbContext>>();
if (dbf != null)
{
try
{
success = true,
answer = result.Answer,
toolCalls = result.ToolInvocations.Select(i => new { tool = i.Tool, arguments = i.Arguments }),
toolCallCount = result.ToolInvocations.Count
});
await using var db = await dbf.CreateDbContextAsync().ConfigureAwait(false);
openPositionsCount = await db.Positions.CountAsync(p => p.ClosedAt == null).ConfigureAwait(false);
}
catch { /* DB noch nicht initialisiert */ }
}
return Results.Ok(new
{
enabled = settings.Enabled,
stopOpening = settings.StopOpening,
onlyDuringMarketHours = settings.OnlyDuringMarketHours,
intervalMinutes = settings.IntervalMinutes,
maxOpenPositions = settings.MaxOpenPositions,
maxNotionalPerTrade = settings.MaxNotionalPerTrade,
dailyBudget = settings.DailyBudget,
minHoldMinutes = settings.MinHoldMinutes,
maxHoldMinutes = settings.MaxHoldMinutes,
tradingMode = trading.Mode,
isPaper = string.Equals(trading.Mode, "Paper", StringComparison.OrdinalIgnoreCase),
guardBlocked = blockedReason != null,
guardBlockedReason = blockedReason,
openPositionsCount
});
});
group.MapGet("/positions", async (IServiceProvider requestSp) =>
{
var dbf = requestSp.GetService<IDbContextFactory<DebugTradingDbContext>>();
if (dbf == null) return Results.Ok(Array.Empty<object>());
try
{
await using var db = await dbf.CreateDbContextAsync().ConfigureAwait(false);
var list = await db.Positions
.OrderByDescending(p => p.OpenedAt)
.Take(100)
.Select(p => new
{
p.Id,
p.ContractKey,
p.Symbol,
p.Currency,
p.Quantity,
p.OpenPrice,
openedAt = p.OpenedAt.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
closeAtUtc = p.CloseAtUtc.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture),
closedAt = p.ClosedAt.HasValue ? p.ClosedAt.Value.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) : null,
isOpen = p.ClosedAt == null,
p.OpenSignalId,
p.Note
})
.ToListAsync().ConfigureAwait(false);
return Results.Ok(list);
}
catch
{
return Results.Ok(Array.Empty<object>());
}
});
group.MapPost("/trigger", async (IServiceProvider requestSp) =>
{
var engine = requestSp.GetService<WorkerEngine>();
if (engine == null)
return Results.BadRequest(new { success = false, message = "WorkerEngine nicht verfügbar." });
try
{
await engine.TriggerWorkerAsync("DBG-TradeGenerator").ConfigureAwait(false);
return Results.Ok(new { success = true, message = "DBG-TradeGenerator ausgelöst." });
}
catch (Exception ex)
{
return Results.BadRequest(new { success = false, message = ex.Message });
}
});
group.MapPost("/toggle-notaus", (IServiceProvider requestSp) =>
{
var settingsService = requestSp.GetService<SettingsService>();
if (settingsService == null)
return Results.BadRequest(new { success = false, message = "SettingsService nicht verfügbar." });
var current = settingsService.Settings.DebugTrading.StopOpening;
settingsService.Settings.DebugTrading.StopOpening = !current;
settingsService.Save();
return Results.Ok(new
{
success = true,
stopOpening = settingsService.Settings.DebugTrading.StopOpening,
message = settingsService.Settings.DebugTrading.StopOpening
? "Not-Aus AKTIV: Keine neuen Testpositionen werden eröffnet."
: "Not-Aus AUS: Neue Testpositionen wieder erlaubt."
});
});
}
}
public sealed record SupervisorAskRequest(string Question, string? Profile);
@@ -10,9 +10,8 @@ using IBKRTrader.Hosting.Web.Endpoints;
using IBKRTrader.Modules.Accounting.Persistence;
using IBKRTrader.Modules.Accounting.Services;
using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.Modules.Supervisor.Agent;
using IBKRTrader.Modules.Supervisor.Persistence;
using IBKRTrader.Modules.Supervisor.Services;
using IBKRTrader.Modules.DebugTrading.Persistence.Ef;
using IBKRTrader.Modules.OptionsWheel.Persistence.Ef;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
@@ -117,9 +116,8 @@ public sealed class WebserverWorker : WorkerBase, IDisposable
ForwardOptional<IIngestRunRepository>(builder.Services);
ForwardOptional<AccountingReportService>(builder.Services);
ForwardOptional<AccountingIngestService>(builder.Services);
ForwardOptional<SupervisorAgent>(builder.Services);
ForwardOptional<DossierService>(builder.Services);
ForwardOptional<ISupervisorReportRepository>(builder.Services);
ForwardOptional<IDbContextFactory<OptionsWheelDbContext>>(builder.Services);
ForwardOptional<IDbContextFactory<DebugTradingDbContext>>(builder.Services);
_app = builder.Build();
+213 -46
View File
@@ -23,10 +23,52 @@
--color-danger: #dc2626;
--color-danger-bg: #fee2e2;
--badge-trading-bg: #e0e7ff;
--badge-trading-text: #3730a3;
--badge-test-bg: #fef3c7;
--badge-test-text: #92400e;
--badge-admin-bg: #d1fae5;
--badge-admin-text: #065f46;
--badge-core-bg: #f1f5f9;
--badge-core-text: #475569;
--font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
[data-theme="dark"] {
--bg-app: #111115;
--bg-card: #18181f;
--bg-toolbar: #1e1e26;
--bg-statusbar: #15151c;
--bg-hover: #262633;
--bg-selected: #223456;
--border-color: #2e2e3d;
--border-subtle: #242430;
--text-main: #e2e4ea;
--text-muted: #959aa8;
--text-light: #646979;
--color-primary: #3b82f6;
--color-primary-hover: #60a5fa;
--color-success: #22c55e;
--color-success-bg: #143322;
--color-warning: #f97316;
--color-warning-bg: #3d2311;
--color-danger: #ef4444;
--color-danger-bg: #3c1618;
--badge-trading-bg: #272652;
--badge-trading-text: #c7d2fe;
--badge-test-bg: #3c2912;
--badge-test-text: #fde68a;
--badge-admin-bg: #133327;
--badge-admin-text: #a7f3d0;
--badge-core-bg: #22222d;
--badge-core-text: #94a3b8;
}
* {
box-sizing: border-box;
margin: 0;
@@ -42,12 +84,13 @@ body {
display: flex;
flex-direction: column;
overflow: hidden;
transition: background-color 0.15s ease, color 0.15s ease;
}
/* ─── App Header & Navigation ─────────────────────────────────────────── */
.app-header {
background: #ffffff;
background: var(--bg-card);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
@@ -56,12 +99,14 @@ body {
height: 48px;
user-select: none;
flex-shrink: 0;
gap: 16px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.brand-title {
@@ -93,24 +138,62 @@ body {
font-weight: 600;
}
/* ─── Navigation Tabs & Grouping ─────────────────────────────────────── */
.nav-tabs {
display: flex;
gap: 4px;
align-items: center;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
flex: 1;
}
.nav-tabs::-webkit-scrollbar {
display: none;
}
.nav-group {
display: flex;
align-items: center;
gap: 2px;
background: var(--bg-app);
padding: 2px 4px;
border-radius: 6px;
border: 1px solid var(--border-subtle);
}
.nav-group-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.6px;
color: var(--text-light);
padding: 0 6px;
text-transform: uppercase;
user-select: none;
}
.nav-divider {
width: 1px;
height: 22px;
background: var(--border-color);
margin: 0 4px;
}
.nav-tab {
background: none;
border: none;
padding: 8px 14px;
padding: 6px 10px;
font-family: inherit;
font-size: 13px;
font-size: 12px;
color: var(--text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
border-radius: 4px;
transition: all 0.15s ease;
white-space: nowrap;
display: inline-flex;
align-items: center;
gap: 6px;
}
.nav-tab:hover {
@@ -121,14 +204,81 @@ body {
.nav-tab.active {
color: var(--color-primary);
font-weight: 600;
border-bottom-color: var(--color-primary);
background: transparent;
background: var(--bg-card);
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
}
/* Tab Badges */
.tab-badge {
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 3px;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.badge-trading {
background: var(--badge-trading-bg);
color: var(--badge-trading-text);
}
.badge-test {
background: var(--badge-test-bg);
color: var(--badge-test-text);
}
.badge-admin {
background: var(--badge-admin-bg);
color: var(--badge-admin-text);
}
.badge-core {
background: var(--badge-core-bg);
color: var(--badge-core-text);
}
/* Category Tags in View Headers */
.view-tag {
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 4px;
letter-spacing: 0.3px;
text-transform: uppercase;
}
.tag-trading {
background: var(--badge-trading-bg);
color: var(--badge-trading-text);
border: 1px solid var(--border-subtle);
}
.tag-test {
background: var(--badge-test-bg);
color: var(--badge-test-text);
border: 1px solid var(--border-subtle);
}
.tag-module {
background: var(--badge-admin-bg);
color: var(--badge-admin-text);
border: 1px solid var(--border-subtle);
}
.tag-core {
background: var(--badge-core-bg);
color: var(--badge-core-text);
border: 1px solid var(--border-subtle);
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
gap: 8px;
flex-shrink: 0;
}
/* ─── App Body Layout ─────────────────────────────────────────────────── */
@@ -177,7 +327,7 @@ body {
/* ─── Buttons & Controls ──────────────────────────────────────────────── */
.btn {
background: #ffffff;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 5px 12px;
@@ -194,13 +344,18 @@ body {
.btn:hover {
background: var(--bg-hover);
border-color: #bbb;
border-color: var(--text-muted);
}
.btn:active {
background: var(--bg-selected);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--color-primary);
border-color: var(--color-primary-hover);
@@ -209,7 +364,7 @@ body {
.btn-primary:hover {
background: var(--color-primary-hover);
border-color: #1e40af;
border-color: var(--color-primary);
}
.btn-danger {
@@ -222,20 +377,26 @@ body {
background: #b91c1c;
}
.input, select {
background: #ffffff;
.theme-toggle-btn {
padding: 4px 8px;
font-size: 13px;
line-height: 1;
}
.input, select, textarea {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 4px 8px;
padding: 5px 8px;
font-family: inherit;
font-size: 12px;
color: var(--text-main);
outline: none;
}
.input:focus, select:focus {
.input:focus, select:focus, textarea:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.checkbox-label {
@@ -255,17 +416,17 @@ body {
flex-wrap: wrap;
padding: 12px;
gap: 8px;
background: #ffffff;
background: var(--bg-card);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.kpi-card {
background: #f5f5f5;
background: var(--bg-toolbar);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 10px 14px;
min-width: 160px;
min-width: 150px;
flex: 1;
display: flex;
flex-direction: column;
@@ -291,7 +452,7 @@ body {
.table-container {
flex: 1;
overflow: auto;
background: #ffffff;
background: var(--bg-card);
position: relative;
}
@@ -303,10 +464,10 @@ body {
}
.datagrid th {
background: #fafafa;
background: var(--bg-toolbar);
color: var(--text-muted);
font-weight: 600;
padding: 6px 10px;
padding: 7px 10px;
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
@@ -316,8 +477,8 @@ body {
}
.datagrid td {
padding: 5px 10px;
border-bottom: 1px solid #eeeeee;
padding: 6px 10px;
border-bottom: 1px solid var(--border-subtle);
color: var(--text-main);
white-space: nowrap;
font-feature-settings: "tnum";
@@ -325,7 +486,7 @@ body {
.datagrid tbody tr {
cursor: pointer;
height: 26px;
height: 28px;
}
.datagrid tbody tr:hover {
@@ -333,8 +494,8 @@ body {
}
.datagrid tbody tr.selected {
background: #e0e7ff !important;
color: #1e3a8a;
background: var(--bg-selected) !important;
color: var(--text-main);
}
.datagrid .text-right {
@@ -354,16 +515,16 @@ body {
font-weight: 600;
text-transform: uppercase;
}
.badge-ok { background: #dcfce7; color: #15803d; }
.badge-warn { background: #fef3c7; color: #b45309; }
.badge-error { background: #fee2e2; color: #b91c1c; }
.badge-ok { background: var(--color-success-bg); color: var(--color-success); }
.badge-warn { background: var(--color-warning-bg); color: var(--color-warning); }
.badge-error { background: var(--color-danger-bg); color: var(--color-danger); }
/* ─── Log Viewer ──────────────────────────────────────────────────────── */
.log-scroller {
flex: 1;
background: #1e1e1e;
color: #d4d4d4;
background: #101014;
color: #d4d4d8;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.5;
@@ -376,9 +537,9 @@ body {
.log-line {
padding: 1px 0;
}
.log-info { color: #96d296; }
.log-warn { color: #ffbe3c; }
.log-error { color: #ff5050; font-weight: 600; }
.log-info { color: #86efac; }
.log-warn { color: #fde047; }
.log-error { color: #f87171; font-weight: 600; }
/* ─── Settings Tree Form ──────────────────────────────────────────────── */
@@ -386,10 +547,11 @@ body {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
background: var(--bg-app);
}
.settings-section {
background: #ffffff;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 4px;
margin-bottom: 12px;
@@ -397,9 +559,10 @@ body {
}
.settings-section-header {
background: #fafafa;
background: var(--bg-toolbar);
padding: 8px 14px;
font-weight: 600;
color: var(--text-main);
border-bottom: 1px solid var(--border-color);
cursor: pointer;
display: flex;
@@ -408,10 +571,10 @@ body {
}
.settings-section-body {
padding: 10px 14px;
padding: 12px 16px;
display: grid;
grid-template-columns: 240px 1fr;
row-gap: 8px;
grid-template-columns: 260px 1fr;
row-gap: 10px;
column-gap: 16px;
align-items: center;
}
@@ -423,7 +586,7 @@ body {
.settings-desc {
font-size: 11px;
color: var(--text-muted);
margin-top: 1px;
margin-top: 2px;
}
/* ─── Statusbar ───────────────────────────────────────────────────────── */
@@ -447,18 +610,18 @@ body {
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(2px);
backdrop-filter: blur(3px);
}
.login-card {
background: #ffffff;
background: var(--bg-card);
border-radius: 8px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.25), 0 10px 10px -5px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 380px;
padding: 28px;
@@ -470,6 +633,7 @@ body {
font-weight: 600;
margin-bottom: 6px;
text-align: center;
color: var(--text-main);
}
.login-subtitle {
@@ -488,6 +652,7 @@ body {
font-size: 12px;
font-weight: 500;
margin-bottom: 6px;
color: var(--text-main);
}
.form-input {
@@ -496,12 +661,14 @@ body {
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 13px;
background: var(--bg-toolbar);
color: var(--text-main);
outline: none;
}
.form-input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.login-error {
+57 -10
View File
@@ -5,6 +5,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IBKRTrader</title>
<link rel="stylesheet" href="css/app.css" />
<script>
// Sofortige Theme-Aktivierung ohne Flackern
(function() {
const saved = localStorage.getItem('ibkr_theme');
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
<body>
@@ -15,19 +24,57 @@
<span id="app-mode-badge" class="brand-badge badge-paper">PAPER</span>
</div>
<!-- Gruppierte Navigation: Core-Funktionen vs. Trading- & Spezial-Module -->
<nav class="nav-tabs" id="main-nav">
<button class="nav-tab active" data-view="dashboard">Dashboard</button>
<button class="nav-tab" data-view="workers">Worker</button>
<button class="nav-tab" data-view="positions">Positionen</button>
<button class="nav-tab" data-view="orders">Orders</button>
<button class="nav-tab" data-view="congress">Congress</button>
<button class="nav-tab" data-view="accounting">Accounting</button>
<button class="nav-tab" data-view="supervisor">Supervisor</button>
<button class="nav-tab" data-view="logs">Logs</button>
<button class="nav-tab" data-view="settings">Einstellungen</button>
<!-- CORE BEREICH -->
<div class="nav-group">
<span class="nav-group-label">CORE</span>
<button class="nav-tab active" data-view="dashboard" title="Core: Dashboard & Übersicht">
Dashboard
</button>
<button class="nav-tab" data-view="workers" title="Core: Worker & Hintergrunddienste">
Worker
</button>
<button class="nav-tab" data-view="positions" title="Core: Bestands- und Positionsverwaltung">
Positionen
</button>
<button class="nav-tab" data-view="orders" title="Core: Broker-Orders & Reservierungen">
Orders
</button>
<button class="nav-tab" data-view="logs" title="Core: Echtzeit-Systemprotokoll">
Logs
</button>
<button class="nav-tab" data-view="settings" title="Core: Systemkonfiguration">
Einstellungen
</button>
</div>
<div class="nav-divider"></div>
<!-- MODULE BEREICH -->
<div class="nav-group">
<span class="nav-group-label">MODULE</span>
<button class="nav-tab" data-view="congress" title="Trading-Modul: Congress Trading (Buch: CT)">
Congress Trading
<span class="tab-badge badge-trading">TRADING</span>
</button>
<button class="nav-tab" data-view="optionswheel" title="Trading-Modul: OptionsWheel (Buch: OW)">
OptionsWheel
<span class="tab-badge badge-trading">TRADING</span>
</button>
<button class="nav-tab" data-view="debugtrading" title="Test-Tradingmodul: Debug Trading (Buch: DBG)">
Debug Trading
<span class="tab-badge badge-test">TEST</span>
</button>
<button class="nav-tab" data-view="accounting" title="Finanz-Modul: Accounting (Buchhaltung & BWA)">
Accounting
<span class="tab-badge badge-admin">FINANZEN</span>
</button>
</div>
</nav>
<div style="display: flex; align-items: center; gap: 8px;">
<div class="header-actions">
<button id="theme-toggle-btn" class="btn theme-toggle-btn" title="Zwischen Hell- und Dunkelmodus wechseln">🌙</button>
<button id="logout-btn" class="btn" style="padding: 4px 10px; font-size: 11px;">🔒 Abmelden</button>
</div>
</header>
+34 -5
View File
@@ -7,8 +7,9 @@ import { WorkersView } from './views/workers.js';
import { PositionsView } from './views/positions.js';
import { OrdersView } from './views/orders.js';
import { CongressTradingView } from './views/congress.js';
import { OptionsWheelView } from './views/optionswheel.js';
import { DebugTradingView } from './views/debugtrading.js';
import { AccountingView } from './views/accounting.js';
import { SupervisorView } from './views/supervisor.js';
import { LogsView } from './views/logs.js';
import { SettingsView } from './views/settings.js';
@@ -18,25 +19,31 @@ class App {
this.statusMsgEl = document.getElementById('status-bar-message');
this.clockEl = document.getElementById('status-bar-clock');
this.modeBadgeEl = document.getElementById('app-mode-badge');
this.themeToggleBtn = document.getElementById('theme-toggle-btn');
this.currentView = null;
this.currentViewName = null;
this.views = {
// Core-Funktionen
dashboard: DashboardView,
workers: WorkersView,
positions: PositionsView,
orders: OrdersView,
congress: CongressTradingView,
accounting: AccountingView,
supervisor: SupervisorView,
logs: LogsView,
settings: SettingsView
settings: SettingsView,
// Module
congress: CongressTradingView,
optionswheel: OptionsWheelView,
debugtrading: DebugTradingView,
accounting: AccountingView
};
this.setStatus = this.setStatus.bind(this);
}
init() {
this.initTheme();
this.startClock();
this.wireNav();
@@ -63,6 +70,28 @@ class App {
});
}
initTheme() {
const updateButton = (theme) => {
if (this.themeToggleBtn) {
this.themeToggleBtn.textContent = theme === 'dark' ? '☀️' : '🌙';
this.themeToggleBtn.title = theme === 'dark' ? 'Zu hellem Modus wechseln' : 'Zu dunklem Modus wechseln';
}
};
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
updateButton(currentTheme);
if (this.themeToggleBtn) {
this.themeToggleBtn.addEventListener('click', () => {
const active = document.documentElement.getAttribute('data-theme') || 'light';
const next = active === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('ibkr_theme', next);
updateButton(next);
});
}
}
startClock() {
const updateClock = () => {
const now = new Date();
@@ -17,7 +17,9 @@ export class AccountingView {
this.container.innerHTML = `
<div class="toolbar" style="flex-wrap: wrap; gap: 8px;">
<div class="toolbar-group" style="flex-wrap: wrap; gap: 8px;">
<label style="font-weight: 500; font-size: 12px; display: flex; align-items: center; gap: 4px;">
<span class="view-tag tag-module">FINANZ-MODUL</span>
<span style="font-weight: 600; font-size: 13px;">Accounting</span>
<label style="font-weight: 500; font-size: 12px; display: flex; align-items: center; gap: 4px; margin-left: 6px;">
Von: <input type="date" id="acc-from" class="input" value="${monthAgo}" style="width: 125px; padding: 3px 6px;" />
</label>
<label style="font-weight: 500; font-size: 12px; display: flex; align-items: center; gap: 4px;">
@@ -12,7 +12,9 @@ export class CongressTradingView {
this.container.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<button id="ct-refresh-btn" class="btn">🔄 Aktualisieren</button>
<span class="view-tag tag-trading">TRADING-MODUL · BUCH CT</span>
<span style="font-weight: 600; font-size: 13px; margin-left: 4px;">Congress Trading</span>
<button id="ct-refresh-btn" class="btn" style="margin-left: 8px;">🔄 Aktualisieren</button>
<button id="ct-scrape-btn" class="btn btn-primary" title="Löst den CT-Scrape-Worker sofort aus.">⚡ Scrape jetzt</button>
</div>
<div id="ct-status-text" style="color: var(--text-muted); font-size: 11px;"></div>
@@ -12,7 +12,9 @@ export class DashboardView {
this.container.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<button id="dash-refresh-btn" class="btn">🔄 Aktualisieren</button>
<span class="view-tag tag-core">CORE</span>
<span style="font-weight: 600; font-size: 13px; margin-left: 4px;">Dashboard</span>
<button id="dash-refresh-btn" class="btn" style="margin-left: 8px;">🔄 Aktualisieren</button>
<span id="dash-mode-text" style="font-weight: 500; margin-left: 8px;"></span>
</div>
<div id="dash-status-text" style="color: var(--text-muted); font-size: 11px;"></div>
@@ -0,0 +1,220 @@
// ─── DebugTrading Test-Tradingmodul View ───
import { api } from '../api.js';
export class DebugTradingView {
constructor(container, setStatus) {
this.container = container;
this.setStatus = setStatus;
this.statusData = null;
}
async render() {
this.container.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<span class="view-tag tag-test">TEST-TRADINGMODUL · BUCH DBG</span>
<span style="font-weight: 600; font-size: 13px; margin-left: 4px;">Debug Trading</span>
<button id="dbg-refresh-btn" class="btn" style="margin-left: 8px;">🔄 Aktualisieren</button>
<button id="dbg-notaus-btn" class="btn btn-danger">🛑 Not-Aus umschalten</button>
<button id="dbg-trigger-btn" class="btn btn-primary" title="Löst den DBG-TradeGenerator sofort aus">⚡ Trade-Generator auslösen</button>
</div>
<div id="dbg-status-text" style="color: var(--text-muted); font-size: 11px;"></div>
</div>
<!-- Sicherheits-Banner -->
<div id="dbg-guard-banner" style="padding: 10px 14px; border-bottom: 1px solid var(--border-color); font-size: 12px; line-height: 1.5; display: flex; align-items: center; justify-content: space-between;">
<div id="dbg-guard-text">Prüfe Sicherheitsbedingungen …</div>
<span style="font-size: 11px; color: var(--text-muted);">Zweck: Buchungsketten-Dauerlast &amp; Neustart-Sicherheit</span>
</div>
<!-- KPI Panel -->
<div class="kpi-panel" id="dbg-kpis">
<div class="kpi-card">
<div class="kpi-caption">Modul-Status</div>
<div class="kpi-value" id="kpi-dbg-enabled">–</div>
</div>
<div class="kpi-card">
<div class="kpi-caption">Not-Aus (Neueröffnungen)</div>
<div class="kpi-value" id="kpi-dbg-notaus">–</div>
</div>
<div class="kpi-card">
<div class="kpi-caption">Offene Testpositionen</div>
<div class="kpi-value" id="kpi-dbg-open">–</div>
</div>
<div class="kpi-card">
<div class="kpi-caption">Tagesbudget</div>
<div class="kpi-value" id="kpi-dbg-budget">–</div>
</div>
<div class="kpi-card">
<div class="kpi-caption">Max. Nominal / Trade</div>
<div class="kpi-value" id="kpi-dbg-max-trade">–</div>
</div>
<div class="kpi-card">
<div class="kpi-caption">Takt &amp; Haltedauer</div>
<div class="kpi-value" id="kpi-dbg-timing" style="font-size: 15px;">–</div>
</div>
</div>
<!-- Testpositionen Tabelle -->
<div class="table-container">
<div style="padding: 8px 12px; font-weight: 600; color: var(--text-muted); font-size: 11px; text-transform: uppercase; display: flex; justify-content: space-between; align-items: center;">
<span>Testpositionen (Tabelle: dbg_position – überleben Neustarts und Tagesgrenzen)</span>
<span id="dbg-table-count" style="font-weight: normal; color: var(--text-light);">0 Einträge</span>
</div>
<table class="datagrid">
<thead>
<tr>
<th style="width: 60px;">ID</th>
<th style="width: 130px;">Kontrakt</th>
<th style="width: 80px;">Symbol</th>
<th style="width: 70px;">Währung</th>
<th class="text-right" style="width: 70px;">Stück</th>
<th class="text-right" style="width: 90px;">Einstand</th>
<th style="width: 130px;">Eröffnet am</th>
<th style="width: 130px;">Fällig am (UTC)</th>
<th style="width: 130px;">Geschlossen am</th>
<th style="width: 90px; text-align: center;">Status</th>
<th>Notiz / Signal-ID</th>
</tr>
</thead>
<tbody id="dbg-positions-tbody">
<tr><td colspan="11" style="text-align:center; color: var(--text-muted);">Lade Testpositionen …</td></tr>
</tbody>
</table>
</div>
`;
document.getElementById('dbg-refresh-btn').addEventListener('click', () => this.refresh());
document.getElementById('dbg-notaus-btn').addEventListener('click', () => this.toggleNotAus());
document.getElementById('dbg-trigger-btn').addEventListener('click', () => this.triggerWorker());
await this.refresh();
}
async refresh() {
this.setStatus('Lade DebugTrading-Status …');
try {
const data = await api.get('/api/modules/debugtrading/status');
if (!data) return;
this.statusData = data;
// Guard Banner
const guardBanner = document.getElementById('dbg-guard-banner');
const guardText = document.getElementById('dbg-guard-text');
if (data.guardBlocked) {
guardBanner.style.background = 'var(--color-danger-bg)';
guardBanner.style.borderColor = 'var(--color-danger)';
guardText.innerHTML = `<strong style="color: var(--color-danger);">⛔ SICHERHEITS-SPERRE:</strong> ${data.guardBlockedReason || 'Nicht im Paper-Modus'}`;
} else {
guardBanner.style.background = 'var(--color-success-bg)';
guardBanner.style.borderColor = 'var(--color-success)';
guardText.innerHTML = `<strong style="color: var(--color-success);">✓ SICHERHEITS-PRÜFUNG OK:</strong> Paper-Modus aktiv. Testbetrieb zulässig.`;
}
// KPIs
const enabledEl = document.getElementById('kpi-dbg-enabled');
enabledEl.textContent = data.enabled ? 'AKTIV' : 'Inaktiv';
enabledEl.style.color = data.enabled ? 'var(--color-success)' : 'var(--text-muted)';
const notausEl = document.getElementById('kpi-dbg-notaus');
notausEl.textContent = data.stopOpening ? 'STOPP (Aktiv)' : 'Aus';
notausEl.style.color = data.stopOpening ? 'var(--color-danger)' : 'var(--color-success)';
document.getElementById('kpi-dbg-open').textContent = `${data.openPositionsCount || 0} / ${data.maxOpenPositions || 5}`;
document.getElementById('kpi-dbg-budget').textContent = `${Number(data.dailyBudget || 10000).toLocaleString('de-DE')} $`;
document.getElementById('kpi-dbg-max-trade').textContent = `${Number(data.maxNotionalPerTrade || 1000).toLocaleString('de-DE')} $`;
document.getElementById('kpi-dbg-timing').textContent = `alle ${data.intervalMinutes || 15}m | Halt: ${data.minHoldMinutes || 10}–${data.maxHoldMinutes || 4320}m`;
const notAusBtn = document.getElementById('dbg-notaus-btn');
if (notAusBtn) {
notAusBtn.textContent = data.stopOpening ? '🟢 Not-Aus aufheben' : '🛑 Not-Aus aktivieren';
notAusBtn.className = data.stopOpening ? 'btn btn-primary' : 'btn btn-danger';
}
const now = new Date().toLocaleTimeString('de-DE');
document.getElementById('dbg-status-text').textContent = `Stand: ${now}`;
this.setStatus(`DebugTrading aktualisiert um ${now}.`);
await this.loadPositions();
} catch (e) {
console.error(e);
this.setStatus(`Fehler: ${e.message}`);
}
}
async loadPositions() {
try {
const positions = await api.get('/api/modules/debugtrading/positions');
const tbody = document.getElementById('dbg-positions-tbody');
const countEl = document.getElementById('dbg-table-count');
if (!tbody) return;
if (!Array.isArray(positions) || positions.length === 0) {
tbody.innerHTML = `<tr><td colspan="11" style="text-align:center; color: var(--text-muted);">Keine Testpositionen in dbg_position vorhanden.</td></tr>`;
if (countEl) countEl.textContent = '0 Einträge';
} else {
if (countEl) countEl.textContent = `${positions.length} Einträge`;
tbody.innerHTML = positions.map(p => `
<tr>
<td>${p.id}</td>
<td style="font-family: var(--font-mono); font-size: 11px; font-weight: 500;">${p.contractKey}</td>
<td style="font-weight: 600;">${p.symbol || '–'}</td>
<td>${p.currency || '–'}</td>
<td class="text-right">${p.quantity}</td>
<td class="text-right">${Number(p.openPrice || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })}</td>
<td>${p.openedAt}</td>
<td style="font-family: var(--font-mono); font-size: 11px;">${p.closeAtUtc}</td>
<td>${p.closedAt || '–'}</td>
<td class="text-center">
<span class="badge ${p.isOpen ? 'badge-ok' : ''}">${p.isOpen ? 'OFFEN' : 'Geschlossen'}</span>
</td>
<td style="font-size: 11px; color: var(--text-muted);">${p.openSignalId || p.note || '–'}</td>
</tr>
`).join('');
}
} catch (e) {
console.warn(e);
}
}
async toggleNotAus() {
const btn = document.getElementById('dbg-notaus-btn');
btn.disabled = true;
this.setStatus('Schalte Not-Aus um …');
try {
const res = await api.post('/api/modules/debugtrading/toggle-notaus');
if (res && res.success) {
alert(res.message);
}
await this.refresh();
} catch (e) {
alert(`Fehler beim Umschalten des Not-Aus: ${e.message}`);
} finally {
btn.disabled = false;
}
}
async triggerWorker() {
const btn = document.getElementById('dbg-trigger-btn');
btn.disabled = true;
btn.textContent = '⏳ Starte Worker …';
this.setStatus('Löse DBG-TradeGenerator aus …');
try {
const res = await api.post('/api/modules/debugtrading/trigger');
if (res && res.success) {
alert(res.message || 'Worker erfolgreich ausgelöst.');
} else {
alert(`Fehler: ${res?.message || 'Unbekannter Fehler'}`);
}
await this.refresh();
} catch (e) {
alert(`Fehler beim Starten des Workers: ${e.message}`);
} finally {
btn.disabled = false;
btn.textContent = '⚡ Trade-Generator auslösen';
}
}
}
@@ -16,8 +16,10 @@ export class LogsView {
this.container.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<input type="text" id="log-filter-input" class="input" placeholder="Filter (z. B. CT, Core, Warn) …" style="width: 240px;" />
<button id="log-clear-btn" class="btn">Geleert</button>
<span class="view-tag tag-core">CORE</span>
<span style="font-weight: 600; font-size: 13px; margin-left: 4px;">Logs</span>
<input type="text" id="log-filter-input" class="input" placeholder="Filter (z. B. CT, Core, Warn) …" style="width: 240px; margin-left: 8px;" />
<button id="log-clear-btn" class="btn">Leeren</button>
<button id="log-copy-btn" class="btn">📋 Kopieren</button>
<label class="checkbox-label" style="margin-left: 8px;">
<input type="checkbox" id="log-autoscroll-chk" checked />
@@ -60,8 +62,9 @@ export class LogsView {
this.eventSource.close();
}
const token = api.token;
const url = `/api/logs/stream${token ? `?token=${encodeURIComponent(token)}` : ''}`;
// Kein Token in der URL: EventSource sendet das HttpOnly-Cookie ibkr_session von selbst
// (gleicher Ursprung). Ein Token im Query-String landete in Server-Logs und im Verlauf.
const url = '/api/logs/stream';
this.setStatus('Verbinde mit Live-Log-Stream (SSE) …');
@@ -0,0 +1,400 @@
// ─── OptionsWheel Trading-Modul View ───
import { api } from '../api.js';
export class OptionsWheelView {
constructor(container, setStatus) {
this.container = container;
this.setStatus = setStatus;
this.currentSubTab = 'watchlist';
this.statusData = null;
}
async render() {
this.container.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<span class="view-tag tag-trading">TRADING-MODUL · BUCH OW</span>
<span style="font-weight: 600; font-size: 13px; margin-left: 4px;">OptionsWheel</span>
<button id="ow-refresh-btn" class="btn" style="margin-left: 8px;">🔄 Aktualisieren</button>
</div>
<div id="ow-status-text" style="color: var(--text-muted); font-size: 11px;"></div>
</div>
<!-- Blocker-Hinweis -->
<div id="ow-blocker-banner" style="display: none; padding: 10px 14px; background: var(--color-warning-bg); border-bottom: 1px solid var(--color-warning); color: var(--color-warning); font-size: 12px; line-height: 1.5;">
<strong>⚠️ VORAUSSETZUNG IM CORE FEHLT:</strong> <span id="ow-blocker-msg"></span>
</div>
<!-- KPI Panel -->
<div class="kpi-panel" id="ow-kpis">
<div class="kpi-card">
<div class="kpi-caption">Modul-Status</div>
<div class="kpi-value" id="kpi-ow-enabled">–</div>
</div>
<div class="kpi-card">
<div class="kpi-caption">Not-Aus (Neueröffnungen)</div>
<div class="kpi-value" id="kpi-ow-notaus">–</div>
</div>
<div class="kpi-card">
<div class="kpi-caption">Basiswerte (Watchlist)</div>
<div class="kpi-value" id="kpi-ow-underlyings">–</div>
</div>
<div class="kpi-card">
<div class="kpi-caption">Aktive Zyklen / Legs</div>
<div class="kpi-value" id="kpi-ow-cycles-legs">–</div>
</div>
<div class="kpi-card">
<div class="kpi-caption">Max. Kapitalbindung</div>
<div class="kpi-value" id="kpi-ow-capital">–</div>
</div>
<div class="kpi-card">
<div class="kpi-caption">Scan-Takt</div>
<div class="kpi-value" id="kpi-ow-interval">–</div>
</div>
</div>
<!-- Sub-Nav Tabs -->
<div style="background: var(--bg-card); border-bottom: 1px solid var(--border-color); display: flex; gap: 4px; padding: 0 12px;">
<button class="nav-tab active" data-subtab="watchlist" id="ow-tab-watchlist">Watchlist (Basiswerte)</button>
<button class="nav-tab" data-subtab="cycles" id="ow-tab-cycles">Zyklen &amp; Legs</button>
<button class="nav-tab" data-subtab="candidates" id="ow-tab-candidates">Scan-Kandidaten</button>
<button class="nav-tab" data-subtab="rules" id="ow-tab-rules">Regelwerk</button>
</div>
<div id="ow-subcontent" style="flex: 1; display: flex; flex-direction: column; overflow: hidden;">
<!-- Subtab Content -->
</div>
`;
document.getElementById('ow-refresh-btn').addEventListener('click', () => this.refresh());
const subtabs = this.container.querySelectorAll('[data-subtab]');
subtabs.forEach(tab => {
tab.addEventListener('click', () => {
subtabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
this.currentSubTab = tab.getAttribute('data-subtab');
this.renderSubTab();
});
});
await this.refresh();
}
async refresh() {
this.setStatus('Lade OptionsWheel-Status …');
try {
const data = await api.get('/api/modules/optionswheel/status');
if (!data) return;
this.statusData = data;
// Blocker Banner
const blockerEl = document.getElementById('ow-blocker-banner');
const blockerMsg = document.getElementById('ow-blocker-msg');
if (data.blocker) {
blockerEl.style.display = 'block';
blockerMsg.textContent = data.blocker;
} else {
blockerEl.style.display = 'none';
}
// KPIs
const enabledEl = document.getElementById('kpi-ow-enabled');
enabledEl.textContent = data.enabled ? 'AKTIV' : 'Inaktiv';
enabledEl.style.color = data.enabled ? 'var(--color-success)' : 'var(--text-muted)';
const notAusEl = document.getElementById('kpi-ow-notaus');
notAusEl.textContent = data.noNewPositions ? 'STOPP (Aktiv)' : 'Normal';
notAusEl.style.color = data.noNewPositions ? 'var(--color-warning)' : 'var(--color-success)';
document.getElementById('kpi-ow-underlyings').textContent = (data.underlyingsCount || 0).toLocaleString('de-DE');
document.getElementById('kpi-ow-cycles-legs').textContent = `${data.activeCyclesCount || 0} / ${data.openLegsCount || 0}`;
document.getElementById('kpi-ow-capital').textContent = `${data.maxBoundCapitalPercent || 25} %`;
document.getElementById('kpi-ow-interval').textContent = `${data.scanIntervalMinutes || 30} min`;
const now = new Date().toLocaleTimeString('de-DE');
document.getElementById('ow-status-text').textContent = `Stand: ${now}`;
this.setStatus(`OptionsWheel aktualisiert um ${now}.`);
await this.renderSubTab();
} catch (e) {
console.error(e);
this.setStatus(`Fehler: ${e.message}`);
}
}
async renderSubTab() {
const sub = document.getElementById('ow-subcontent');
if (!sub) return;
if (this.currentSubTab === 'watchlist') {
sub.innerHTML = `
<div class="table-container">
<div style="padding: 8px 12px; font-weight: 600; color: var(--text-muted); font-size: 11px; text-transform: uppercase;">
Definierte Basiswerte (Whitelist: Nur diese Ticker werden gehandelt)
</div>
<table class="datagrid">
<thead>
<tr>
<th style="width: 120px;">Symbol</th>
<th style="width: 90px; text-align: center;">Status</th>
<th style="width: 100px; text-align: center;">Gesperrt</th>
<th>Sperrgrund</th>
<th style="width: 130px;">Hinzugefügt</th>
<th style="width: 130px;">Aktualisiert</th>
</tr>
</thead>
<tbody id="ow-underlyings-tbody">
<tr><td colspan="6" style="text-align:center; color: var(--text-muted);">Lade Watchlist …</td></tr>
</tbody>
</table>
</div>
`;
await this.loadUnderlyings();
} else if (this.currentSubTab === 'cycles') {
sub.innerHTML = `
<div style="flex: 1; display: flex; flex-direction: column; overflow: hidden;">
<div class="table-container" style="flex: 1; border-bottom: 1px solid var(--border-color);">
<div style="padding: 6px 12px; font-weight: 600; color: var(--text-muted); font-size: 11px; text-transform: uppercase;">
Wheel-Zyklen (ow_cycles)
</div>
<table class="datagrid">
<thead>
<tr>
<th style="width: 60px;">ID</th>
<th style="width: 100px;">Symbol</th>
<th style="width: 130px;">Start</th>
<th style="width: 130px;">Ende</th>
<th class="text-right">Prämie Brutto</th>
<th class="text-right">Prämie Netto</th>
<th class="text-right">Zuteilungskurs</th>
<th class="text-right">Stück</th>
<th class="text-right">Realisiert GuV</th>
<th>Ergebnis</th>
</tr>
</thead>
<tbody id="ow-cycles-tbody">
<tr><td colspan="10" style="text-align:center; color: var(--text-muted);">Lade Zyklen …</td></tr>
</tbody>
</table>
</div>
<div class="table-container" style="flex: 1;">
<div style="padding: 6px 12px; font-weight: 600; color: var(--text-muted); font-size: 11px; text-transform: uppercase;">
Geschriebene Legs / Optionsbeine (ow_legs)
</div>
<table class="datagrid">
<thead>
<tr>
<th style="width: 60px;">ID</th>
<th style="width: 70px;">Zyklus</th>
<th style="width: 90px;">Symbol</th>
<th style="width: 150px;">Kontrakt</th>
<th style="width: 90px;">Verfall</th>
<th class="text-right" style="width: 70px;">Strike</th>
<th style="width: 50px;">Typ</th>
<th class="text-right" style="width: 60px;">Menge</th>
<th class="text-right" style="width: 80px;">Prämie</th>
<th class="text-right" style="width: 60px;">Delta</th>
<th class="text-right" style="width: 60px;">IV</th>
<th class="text-right" style="width: 60px;">DTE</th>
<th>Status</th>
</tr>
</thead>
<tbody id="ow-legs-tbody">
<tr><td colspan="13" style="text-align:center; color: var(--text-muted);">Lade Legs …</td></tr>
</tbody>
</table>
</div>
</div>
`;
await this.loadCyclesAndLegs();
} else if (this.currentSubTab === 'candidates') {
sub.innerHTML = `
<div class="table-container">
<div style="padding: 8px 12px; font-weight: 600; color: var(--text-muted); font-size: 11px; text-transform: uppercase;">
Strike-Kandidaten &amp; Verwerfungsgründe (ow_candidates)
</div>
<table class="datagrid">
<thead>
<tr>
<th style="width: 120px;">Scan-Zeit</th>
<th style="width: 90px;">Symbol</th>
<th style="width: 160px;">Kontrakt</th>
<th style="width: 90px;">Verfall</th>
<th class="text-right" style="width: 70px;">Strike</th>
<th style="width: 50px;">Typ</th>
<th class="text-right" style="width: 60px;">Delta</th>
<th class="text-right" style="width: 60px;">IV</th>
<th class="text-right" style="width: 80px;">Bid / Ask</th>
<th class="text-right" style="width: 80px;">Prämie</th>
<th class="text-right" style="width: 90px;">Rendite p.a.</th>
<th style="width: 70px; text-align: center;">Gewählt</th>
<th>Verwerfungsgrund</th>
</tr>
</thead>
<tbody id="ow-candidates-tbody">
<tr><td colspan="13" style="text-align:center; color: var(--text-muted);">Lade Kandidaten …</td></tr>
</tbody>
</table>
</div>
`;
await this.loadCandidates();
} else if (this.currentSubTab === 'rules') {
const r = this.statusData?.rules || {};
sub.innerHTML = `
<div style="padding: 24px; max-width: 800px; line-height: 1.6;">
<h3 style="margin-bottom: 16px; font-size: 16px; color: var(--text-main);">Strategie-Regelwerk des OptionsWheel-Moduls</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin-bottom: 20px;">
<div class="kpi-card">
<div class="kpi-caption">Restlaufzeit (DTE)</div>
<div class="kpi-value" style="font-size: 16px;">${r.minDte ?? 30} bis ${r.maxDte ?? 45} Tage</div>
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">Optimale Theta-Decay Spanne</div>
</div>
<div class="kpi-card">
<div class="kpi-caption">Delta-Band</div>
<div class="kpi-value" style="font-size: 16px;">${r.minDelta ?? 0.15} bis ${r.maxDelta ?? 0.30}</div>
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">Konservatives OTM-Profil</div>
</div>
<div class="kpi-card">
<div class="kpi-caption">Mindestrendite p.a.</div>
<div class="kpi-value" style="font-size: 16px;">${r.minAnnualizedReturnPercent ?? 12} %</div>
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">Schwellenwert für Eröffnung</div>
</div>
<div class="kpi-card">
<div class="kpi-caption">Roll-Gewinnziel</div>
<div class="kpi-value" style="font-size: 16px;">${r.rollProfitTargetPercent ?? 80} %</div>
<div style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">Vorzeitiges Schließen/Rollen</div>
</div>
</div>
<div style="background: var(--bg-toolbar); border: 1px solid var(--border-color); border-radius: 4px; padding: 14px; font-size: 12px; color: var(--text-main);">
<h4 style="margin-bottom: 6px; font-size: 13px;">Funktionsweise des Wheels:</h4>
<ol style="margin-left: 18px; display: flex; flex-direction: column; gap: 6px;">
<li><b>Schritt 1 (Cash-Secured Put):</b> Verkauf eines OTM-Puts auf einen Qualitäts-Basiswert der Watchlist gegen gesicherte Barreserve.</li>
<li><b>Schritt 2 (Prämieneinzug oder Zuteilung):</b> Verfällt der Put wertlos, wird die Prämie verbucht. Bei Zuteilung werden die Aktien zum Strike-Kurs übernommen.</li>
<li><b>Schritt 3 (Covered Call):</b> Auf den eingebuchten Aktienbestand werden OTM-Calls verkauft, bis die Position per Abruf gewinnbringend glattgestellt wird.</li>
</ol>
</div>
</div>
`;
}
}
async loadUnderlyings() {
try {
const list = await api.get('/api/modules/optionswheel/underlyings');
const tbody = document.getElementById('ow-underlyings-tbody');
if (!tbody) return;
if (!Array.isArray(list) || list.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center; color: var(--text-muted);">Keine Basiswerte in der Watchlist definiert.</td></tr>`;
} else {
tbody.innerHTML = list.map(u => `
<tr>
<td style="font-weight: 600;">${u.symbol}</td>
<td class="text-center">${u.active ? '<span class="badge badge-ok">Aktiv</span>' : '<span class="badge badge-warn">Inaktiv</span>'}</td>
<td class="text-center">${u.halted ? '<span class="badge badge-error">Gesperrt</span>' : '–'}</td>
<td>${u.haltReason || '–'}</td>
<td>${u.createdAt || '–'}</td>
<td>${u.updatedAt || '–'}</td>
</tr>
`).join('');
}
} catch (e) {
console.warn(e);
}
}
async loadCyclesAndLegs() {
try {
const [cycles, legs] = await Promise.all([
api.get('/api/modules/optionswheel/cycles'),
api.get('/api/modules/optionswheel/legs')
]);
const cyclesTbody = document.getElementById('ow-cycles-tbody');
if (cyclesTbody) {
if (!Array.isArray(cycles) || cycles.length === 0) {
cyclesTbody.innerHTML = `<tr><td colspan="10" style="text-align:center; color: var(--text-muted);">Keine Zyklen erfasst.</td></tr>`;
} else {
cyclesTbody.innerHTML = cycles.map(c => `
<tr>
<td>${c.id}</td>
<td style="font-weight: 600;">${c.symbol}</td>
<td>${c.startedAt}</td>
<td>${c.endedAt || '–'}</td>
<td class="text-right">${Number(c.premiumGross || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })} $</td>
<td class="text-right">${Number(c.premiumNet || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })} $</td>
<td class="text-right">${Number(c.assignedPrice || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })}</td>
<td class="text-right">${c.assignedShares || 0}</td>
<td class="text-right" style="font-weight: 600;">${c.realizedPnl !== null ? Number(c.realizedPnl).toLocaleString('de-DE', { minimumFractionDigits: 2 }) + ' $' : '–'}</td>
<td>${c.outcome || 'Aktiv'}</td>
</tr>
`).join('');
}
}
const legsTbody = document.getElementById('ow-legs-tbody');
if (legsTbody) {
if (!Array.isArray(legs) || legs.length === 0) {
legsTbody.innerHTML = `<tr><td colspan="13" style="text-align:center; color: var(--text-muted);">Keine Legs erfasst.</td></tr>`;
} else {
legsTbody.innerHTML = legs.map(l => `
<tr>
<td>${l.id}</td>
<td>${l.cycleId}</td>
<td style="font-weight: 600;">${l.symbol}</td>
<td style="font-family: var(--font-mono); font-size: 11px;">${l.contractKey}</td>
<td>${l.expiry}</td>
<td class="text-right">${Number(l.strike || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })}</td>
<td><b>${l.right}</b></td>
<td class="text-right">${l.contracts}</td>
<td class="text-right">${Number(l.premiumOpen || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })} $</td>
<td class="text-right">${l.deltaOpen !== null ? Number(l.deltaOpen).toLocaleString('de-DE', { minimumFractionDigits: 2 }) : '–'}</td>
<td class="text-right">${l.ivOpen !== null ? Number(l.ivOpen).toLocaleString('de-DE', { minimumFractionDigits: 1 }) : '–'}</td>
<td class="text-right">${l.dteOpen || '–'}</td>
<td><span class="badge ${l.status === 'Open' ? 'badge-ok' : ''}">${l.status}</span></td>
</tr>
`).join('');
}
}
} catch (e) {
console.warn(e);
}
}
async loadCandidates() {
try {
const list = await api.get('/api/modules/optionswheel/candidates');
const tbody = document.getElementById('ow-candidates-tbody');
if (!tbody) return;
if (!Array.isArray(list) || list.length === 0) {
tbody.innerHTML = `<tr><td colspan="13" style="text-align:center; color: var(--text-muted);">Keine Scan-Kandidaten vorhanden.</td></tr>`;
} else {
tbody.innerHTML = list.map(c => `
<tr>
<td>${c.scannedAt}</td>
<td style="font-weight: 600;">${c.symbol}</td>
<td style="font-family: var(--font-mono); font-size: 11px;">${c.contractKey}</td>
<td>${c.expiry}</td>
<td class="text-right">${Number(c.strike || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })}</td>
<td><b>${c.right}</b></td>
<td class="text-right">${c.delta !== null ? Number(c.delta).toLocaleString('de-DE', { minimumFractionDigits: 2 }) : '–'}</td>
<td class="text-right">${c.iv !== null ? Number(c.iv).toLocaleString('de-DE', { minimumFractionDigits: 1 }) : '–'}</td>
<td class="text-right">${Number(c.bid || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })} / ${Number(c.ask || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })}</td>
<td class="text-right">${Number(c.premium || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })} $</td>
<td class="text-right" style="font-weight: 500;">${Number(c.annualizedReturnPercent || 0).toLocaleString('de-DE', { minimumFractionDigits: 1 })} %</td>
<td class="text-center">${c.chosen ? '<span class="badge badge-ok">JA</span>' : '–'}</td>
<td style="color: ${c.chosen ? 'var(--color-success)' : 'var(--text-muted)'};">${c.rejection || c.note || '–'}</td>
</tr>
`).join('');
}
} catch (e) {
console.warn(e);
}
}
}
@@ -14,9 +14,11 @@ export class OrdersView {
this.container.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<button id="ord-refresh-btn" class="btn">🔄 Aktualisieren</button>
<span class="view-tag tag-core">CORE</span>
<span style="font-weight: 600; font-size: 13px; margin-left: 4px;">Orders &amp; Reservierungen</span>
<button id="ord-refresh-btn" class="btn" style="margin-left: 8px;">🔄 Aktualisieren</button>
<button id="ord-cancel-btn" class="btn btn-danger" disabled>❌ Order stornieren</button>
<button id="ord-cancel-all-btn" class="btn btn-danger" style="margin-left: 12px;">⚠️ Alle stornieren (Paper)</button>
<button id="ord-cancel-all-btn" class="btn btn-danger" style="margin-left: 8px;">⚠️ Alle stornieren (Paper)</button>
</div>
<div id="ord-status-summary" style="color: var(--text-muted); font-size: 11px;"></div>
</div>
@@ -14,7 +14,9 @@ export class PositionsView {
this.container.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<button id="pos-refresh-btn" class="btn">🔄 Aktualisieren</button>
<span class="view-tag tag-core">CORE</span>
<span style="font-weight: 600; font-size: 13px; margin-left: 4px;">Positionen</span>
<button id="pos-refresh-btn" class="btn" style="margin-left: 8px;">🔄 Aktualisieren</button>
<label class="checkbox-label" style="margin-left: 8px;">
<input type="checkbox" id="pos-broker-chk" checked />
Broker abfragen (Bestandsvergleich)
@@ -14,7 +14,9 @@ export class SettingsView {
this.container.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<button id="set-save-btn" class="btn btn-primary">💾 Speichern</button>
<span class="view-tag tag-core">CORE</span>
<span style="font-weight: 600; font-size: 13px; margin-left: 4px;">Einstellungen</span>
<button id="set-save-btn" class="btn btn-primary" style="margin-left: 8px;">💾 Speichern</button>
<button id="set-reload-btn" class="btn">🔄 Neu laden</button>
</div>
<div id="set-status-msg" style="color: var(--text-muted); font-size: 11px;"></div>
@@ -1,304 +0,0 @@
// ─── Supervisor View ───
import { api } from '../api.js';
export class SupervisorView {
constructor(container, setStatus) {
this.container = container;
this.setStatus = setStatus;
this.currentSubTab = 'analysis';
this.profiles = [];
this.signals = [];
this.selectedSignal = null;
}
async render() {
this.container.innerHTML = `
<!-- Sub-Nav Tabs -->
<div style="background: #ffffff; border-bottom: 1px solid var(--border-color); display: flex; gap: 4px; padding: 0 12px;">
<button class="nav-tab active" data-subtab="analysis" id="sup-tab-analysis">Analyse</button>
<button class="nav-tab" data-subtab="dossier" id="sup-tab-dossier">Dossier-Browser</button>
<button class="nav-tab" data-subtab="reports" id="sup-tab-reports">Berichte</button>
<button class="nav-tab" data-subtab="info" id="sup-tab-info">Hinweise</button>
</div>
<div id="sup-subcontent" style="flex: 1; display: flex; flex-direction: column; overflow: hidden;">
<!-- Subtab content injected here -->
</div>
`;
const subtabs = this.container.querySelectorAll('[data-subtab]');
subtabs.forEach(tab => {
tab.addEventListener('click', () => {
subtabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
this.currentSubTab = tab.getAttribute('data-subtab');
this.renderSubTab();
});
});
await this.loadProfiles();
await this.renderSubTab();
}
async loadProfiles() {
try {
const data = await api.get('/api/modules/supervisor/profiles');
if (Array.isArray(data)) {
this.profiles = data;
}
} catch (e) {
console.warn('Konnte Supervisor-Profile nicht laden:', e);
}
}
async renderSubTab() {
const sub = document.getElementById('sup-subcontent');
if (!sub) return;
if (this.currentSubTab === 'analysis') {
sub.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<span style="font-weight: 500;">Profil:</span>
<select id="sup-profile-select" class="select" style="min-width: 180px;">
${this.profiles.map(p => `<option value="${p}">${p}</option>`).join('')}
</select>
<button id="sup-ask-btn" class="btn btn-primary">⚡ Fragen</button>
</div>
<div id="sup-ask-status" style="color: var(--text-muted); font-size: 11px;"></div>
</div>
<div style="padding: 10px 14px; background: #ffffff; border-bottom: 1px solid var(--border-color);">
<textarea id="sup-question-input" class="input" style="width: 100%; height: 72px; font-family: var(--font-family); resize: vertical;" placeholder="Frage an den Supervisor eingeben …"></textarea>
</div>
<div id="sup-answer-container" style="flex: 1; background: #14141e; color: #d2d2d2; font-family: var(--font-mono); font-size: 12px; line-height: 1.6; padding: 14px; overflow-y: auto; white-space: pre-wrap; word-break: break-word;">
Wählen Sie ein Profil, geben Sie eine Frage ein und klicken Sie auf 'Fragen'.
</div>
`;
document.getElementById('sup-ask-btn').addEventListener('click', () => this.ask());
document.getElementById('sup-question-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.ask();
}
});
} else if (this.currentSubTab === 'dossier') {
sub.innerHTML = `
<div style="flex: 1; display: flex; overflow: hidden;">
<!-- Left: Signals list -->
<div style="width: 420px; border-right: 1px solid var(--border-color); display: flex; flex-direction: column; background: #ffffff;">
<div class="toolbar" style="border-top: none;">
<button id="sup-load-signals-btn" class="btn">🔄 Signale laden</button>
</div>
<div class="table-container" style="flex: 1;">
<table class="datagrid">
<thead>
<tr>
<th style="width: 100px;">Zeit</th>
<th style="width: 80px;">Symbol</th>
<th style="width: 100px;">Modul</th>
<th>Entscheidung</th>
</tr>
</thead>
<tbody id="sup-signals-tbody">
<tr><td colspan="4" style="text-align:center; color: var(--text-muted);">Signale laden …</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Right: Dossier detail markdown -->
<div style="flex: 1; display: flex; flex-direction: column; background: #14141e;">
<div style="padding: 8px 12px; background: #1e1e2d; border-bottom: 1px solid #2d2d3f; color: #a1a1aa; font-size: 11px; display: flex; justify-content: space-between;">
<span id="sup-dossier-header">Dossier-Details</span>
</div>
<div id="sup-dossier-body" style="flex: 1; overflow-y: auto; padding: 14px; color: #d2d2d2; font-family: var(--font-mono); font-size: 12px; line-height: 1.6; white-space: pre-wrap;">
Signal in der linken Tabelle auswählen.
</div>
</div>
</div>
`;
document.getElementById('sup-load-signals-btn').addEventListener('click', () => this.loadSignals());
await this.loadSignals();
} else if (this.currentSubTab === 'reports') {
sub.innerHTML = `
<div class="toolbar">
<button id="sup-load-reports-btn" class="btn">🔄 Berichte laden</button>
<div id="sup-reports-status" style="color: var(--text-muted); font-size: 11px;"></div>
</div>
<div class="table-container">
<table class="datagrid">
<thead>
<tr>
<th style="width: 140px;">Erstellt</th>
<th style="width: 120px;">Profil</th>
<th style="width: 160px;">Modell</th>
<th>Frage</th>
<th style="width: 60px; text-align: center;">Tools</th>
<th style="width: 110px;">Tokens</th>
</tr>
</thead>
<tbody id="sup-reports-tbody">
<tr><td colspan="6" style="text-align:center; color: var(--text-muted);">Lade Berichte …</td></tr>
</tbody>
</table>
</div>
`;
document.getElementById('sup-load-reports-btn').addEventListener('click', () => this.loadReports());
await this.loadReports();
} else if (this.currentSubTab === 'info') {
sub.innerHTML = `
<div style="padding: 24px; max-width: 800px; color: var(--text-main); line-height: 1.6;">
<h3 style="margin-bottom: 12px; font-size: 16px;">Supervisor Modul-Hinweise</h3>
<div id="sup-info-content">Lade Modul-Informationen …</div>
</div>
`;
await this.loadInfo();
}
}
async ask() {
const qEl = document.getElementById('sup-question-input');
const question = qEl?.value?.trim();
if (!question) {
alert('Bitte eine Frage eingeben.');
return;
}
const pEl = document.getElementById('sup-profile-select');
const profile = pEl?.value;
const btn = document.getElementById('sup-ask-btn');
const ansEl = document.getElementById('sup-answer-container');
const statusEl = document.getElementById('sup-ask-status');
btn.disabled = true;
btn.textContent = '⏳ Analysiere …';
if (statusEl) statusEl.textContent = 'Agent denkt nach …';
ansEl.textContent = 'Agent führt Tool-Aufrufe und Analysen durch … Bitte warten.';
try {
const res = await api.post('/api/modules/supervisor/ask', { question, profile });
if (res && res.success) {
let output = res.answer || '(Keine Antwort erhalten)';
if (res.toolCalls && res.toolCalls.length > 0) {
output += '\n\n── Ausgeführte Tool-Calls ──\n' +
res.toolCalls.map(tc => `• ${tc.tool}: ${JSON.stringify(tc.arguments)}`).join('\n');
}
ansEl.textContent = output;
if (statusEl) statusEl.textContent = `Fertig (${res.toolCallCount || 0} Tools genutzt).`;
} else {
ansEl.textContent = `Fehler: ${res?.message || 'Unbekannter Fehler'}`;
}
} catch (e) {
ansEl.textContent = `Fehler bei der Abfrage: ${e.message}`;
} finally {
btn.disabled = false;
btn.textContent = '⚡ Fragen';
}
}
async loadSignals() {
this.setStatus('Lade Signale …');
try {
const signals = await api.get('/api/modules/supervisor/signals?limit=100');
this.signals = Array.isArray(signals) ? signals : [];
const tbody = document.getElementById('sup-signals-tbody');
if (!tbody) return;
if (this.signals.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" style="text-align:center; color: var(--text-muted);">Keine Signale gefunden.</td></tr>`;
} else {
tbody.innerHTML = this.signals.map((s, idx) => `
<tr data-idx="${idx}" class="${this.selectedSignal && this.selectedSignal.signalId === s.signalId ? 'selected' : ''}">
<td>${s.firstSeen ? s.firstSeen.substring(5, 16) : '–'}</td>
<td style="font-weight: 600;">${s.symbol || '–'}</td>
<td>${s.module || '–'}</td>
<td>${s.lastDecision || '–'}</td>
</tr>
`).join('');
tbody.querySelectorAll('tr').forEach(tr => {
tr.addEventListener('click', () => {
const idx = parseInt(tr.getAttribute('data-idx'), 10);
this.selectSignal(this.signals[idx]);
});
});
}
this.setStatus(`Signale geladen (${this.signals.length}).`);
} catch (e) {
this.setStatus(`Fehler: ${e.message}`);
}
}
async selectSignal(s) {
this.selectedSignal = s;
const headerEl = document.getElementById('sup-dossier-header');
const bodyEl = document.getElementById('sup-dossier-body');
if (!bodyEl) return;
if (headerEl) headerEl.textContent = `Dossier: ${s.symbol} (${s.signalId})`;
bodyEl.textContent = 'Lade Dossier …';
try {
const data = await api.get(`/api/modules/supervisor/dossier/${encodeURIComponent(s.signalId)}`);
if (data && data.markdown) {
bodyEl.textContent = data.markdown;
} else {
bodyEl.textContent = 'Kein Dossier für dieses Signal verfügbar.';
}
} catch (e) {
bodyEl.textContent = `Fehler beim Laden des Dossiers: ${e.message}`;
}
}
async loadReports() {
this.setStatus('Lade Berichte …');
try {
const reports = await api.get('/api/modules/supervisor/reports?limit=100');
const tbody = document.getElementById('sup-reports-tbody');
if (!tbody) return;
if (!Array.isArray(reports) || reports.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center; color: var(--text-muted);">Keine Berichte vorhanden.</td></tr>`;
} else {
tbody.innerHTML = reports.map(r => `
<tr title="${(r.answer || '').replace(/"/g, '&quot;')}">
<td>${r.createdAt}</td>
<td><b>${r.profile}</b></td>
<td style="font-family: var(--font-mono); font-size: 11px;">${r.model}</td>
<td>${r.question}</td>
<td class="text-center">${r.toolCallCount}</td>
<td style="font-size: 11px;">${r.tokens}</td>
</tr>
`).join('');
}
this.setStatus(`Berichte geladen (${reports ? reports.length : 0}).`);
} catch (e) {
this.setStatus(`Fehler: ${e.message}`);
}
}
async loadInfo() {
try {
const info = await api.get('/api/modules/supervisor/info');
const el = document.getElementById('sup-info-content');
if (!el || !info) return;
el.innerHTML = `
<div style="margin-bottom: 16px; padding: 12px; border-radius: 4px; border: 1px solid ${info.openRouterKeyConfigured ? '#86efac' : '#fde68a'}; background: ${info.openRouterKeyConfigured ? '#f0fdf4' : '#fffbeb'}; color: ${info.openRouterKeyConfigured ? '#15803d' : '#b45309'};">
<b>OpenRouter API-Key:</b> ${info.openRouterKeyConfigured ? '✅ Konfiguriert' : '⚠️ Nicht gesetzt (in settings.json oder OPENROUTER_API_KEY hinterlegen)'}
</div>
<p style="margin-bottom: 8px;"><b>Zeitzone:</b> ${info.timeZone || '–'}</p>
<p style="margin-bottom: 8px;"><b>Beschreibung:</b> ${info.description || '–'}</p>
`;
} catch (e) {
console.error(e);
}
}
}
@@ -14,7 +14,9 @@ export class WorkersView {
this.container.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<button id="workers-trigger-btn" class="btn btn-primary" disabled>⚡ Jetzt ausführen</button>
<span class="view-tag tag-core">CORE</span>
<span style="font-weight: 600; font-size: 13px; margin-left: 4px;">Worker / Services</span>
<button id="workers-trigger-btn" class="btn btn-primary" style="margin-left: 8px;" disabled>⚡ Jetzt ausführen</button>
<button id="workers-refresh-btn" class="btn">🔄 Aktualisieren</button>
</div>
<div id="workers-status-text" style="color: var(--text-muted); font-size: 11px;"></div>
@@ -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();
}
}
+30 -4
View File
@@ -83,19 +83,45 @@ public class AuthServiceTests
sut.IsAuthenticated(ctx).Should().BeTrue();
}
/// <summary>
/// Der Token im Query-String gilt <b>nur</b> für den SSE-Strom. EventSource kann keine
/// Kopfzeilen setzen – das ist der einzige Grund, aus dem es diesen Weg gibt.
/// </summary>
[Fact]
public void IsAuthenticated_RecognizesQueryToken()
public void IsAuthenticated_AcceptsQueryToken_OnTheLogStream()
{
var settings = new SettingsService();
settings.Settings.Webserver.AuthToken = "my-secret-key";
var sut = new AuthService(settings);
var sut = SutWithToken("my-secret-key");
var ctx = new Microsoft.AspNetCore.Http.DefaultHttpContext();
ctx.Request.Path = "/api/logs/stream";
ctx.Request.QueryString = new Microsoft.AspNetCore.Http.QueryString("?token=my-secret-key");
sut.IsAuthenticated(ctx).Should().BeTrue();
}
/// <summary>
/// Überall sonst zählt er nicht. Ein Query-String landet in Server-Logs, im Browserverlauf und
/// in Referer-Kopfzeilen; ein Geheimnis hat dort nichts zu suchen, wo ein anderer Weg offensteht.
/// </summary>
[Fact]
public void IsAuthenticated_IgnoresQueryToken_EverywhereElse()
{
var sut = SutWithToken("my-secret-key");
var ctx = new Microsoft.AspNetCore.Http.DefaultHttpContext();
ctx.Request.Path = "/api/positions";
ctx.Request.QueryString = new Microsoft.AspNetCore.Http.QueryString("?token=my-secret-key");
sut.IsAuthenticated(ctx).Should().BeFalse();
}
private static AuthService SutWithToken(string token)
{
var settings = new SettingsService();
settings.Settings.Webserver.AuthToken = token;
return new AuthService(settings);
}
[Fact]
public void IsAuthenticated_RejectsUnauthenticatedRequest()
{
@@ -0,0 +1,75 @@
using FluentAssertions;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Hosting.Web.Endpoints;
using IBKRTrader.Modules.CongressTrading;
using IBKRTrader.Modules.DebugTrading;
using IBKRTrader.Modules.OptionsWheel;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.Tests.Web;
[Trait("cat", "unit")]
public class ModuleEndpointsTests
{
[Fact]
public void Map_RegistersEndpoints_ForConfiguredModules()
{
var services = new ServiceCollection();
services.AddSingleton<IModule>(new CongressTradingModule());
services.AddSingleton<IModule>(new OptionsWheelModule());
services.AddSingleton<IModule>(new DebugTradingModule());
var sp = services.BuildServiceProvider();
var builder = WebApplication.CreateBuilder();
var app = builder.Build();
var api = app.MapGroup("/api");
// Act
ModuleEndpoints.Map(api, sp);
// Assert - Verify registered endpoints
var routeBuilder = (IEndpointRouteBuilder)app;
var patterns = routeBuilder.DataSources
.SelectMany(ds => ds.Endpoints)
.OfType<RouteEndpoint>()
.Select(e => e.RoutePattern.RawText)
.ToList();
patterns.Should().Contain(p => p != null && p.Contains("/modules/congresstrading"));
patterns.Should().Contain(p => p != null && p.Contains("/modules/optionswheel/status"));
patterns.Should().Contain(p => p != null && p.Contains("/modules/optionswheel/underlyings"));
patterns.Should().Contain(p => p != null && p.Contains("/modules/debugtrading/status"));
patterns.Should().Contain(p => p != null && p.Contains("/modules/debugtrading/positions"));
// Verify Supervisor is NOT registered
patterns.Should().NotContain(p => p != null && p.Contains("/supervisor"));
}
[Fact]
public void Map_DoesNotRegisterEndpoints_WhenModulesOmitted()
{
var services = new ServiceCollection();
// Empty modules list
var sp = services.BuildServiceProvider();
var builder = WebApplication.CreateBuilder();
var app = builder.Build();
var api = app.MapGroup("/api");
// Act
ModuleEndpoints.Map(api, sp);
// Assert - None of the module endpoints should be mapped
var routeBuilder = (IEndpointRouteBuilder)app;
var patterns = routeBuilder.DataSources
.SelectMany(ds => ds.Endpoints)
.OfType<RouteEndpoint>()
.Select(e => e.RoutePattern.RawText)
.ToList();
patterns.Should().BeEmpty();
}
}