Compare commits
4
Commits
a94f94002f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6189d1bf8c | ||
|
|
2ca5bc6553 | ||
|
|
362207c6f2 | ||
|
|
7b16474a33 |
@@ -75,9 +75,13 @@ public sealed class OrderManager
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
brokerOrders = await _orderBook.GetOpenOrdersAsync(ct).ConfigureAwait(false);
|
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.
|
// UseTwsApi ist eine EINSTELLUNG, kein Zustand: Sie steht auch dann auf true, wenn TWS
|
||||||
reachable = brokerOrders.Count > 0 || _settings.Settings.IBKR.UseTwsApi;
|
// 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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -163,8 +163,9 @@ public class WebserverSettings
|
|||||||
|
|
||||||
[Category("Webserver")]
|
[Category("Webserver")]
|
||||||
[DisplayName("Netzwerkzugriff erlauben")]
|
[DisplayName("Netzwerkzugriff erlauben")]
|
||||||
[Description("Wenn aktiviert, lauscht der Server auf allen Schnittstellen (0.0.0.0), sonst nur lokal (127.0.0.1)")]
|
[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;
|
"Vorgabe ist lokal: Der Server spricht Klartext-HTTP, der Token ginge im Netz mit.")]
|
||||||
|
public bool AllowRemoteAccess { get; set; }
|
||||||
|
|
||||||
[Category("Webserver")]
|
[Category("Webserver")]
|
||||||
[DisplayName("Auth-Token")]
|
[DisplayName("Auth-Token")]
|
||||||
@@ -346,6 +347,14 @@ public class DebugTradingSettings
|
|||||||
"eröffnet. Der schonende Halt - Abschalten ließe offene Positionen unbetreut.")]
|
"eröffnet. Der schonende Halt - Abschalten ließe offene Positionen unbetreut.")]
|
||||||
public bool StopOpening { get; set; } = false;
|
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")]
|
[Category("DebugTrading")]
|
||||||
[DisplayName("Takt (Minuten)")]
|
[DisplayName("Takt (Minuten)")]
|
||||||
[Description("Abstand zwischen zwei Läufen. Je Lauf wird höchstens eine Position eröffnet.")]
|
[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.Logging;
|
||||||
using IBKRTrader.Core.Settings;
|
using IBKRTrader.Core.Settings;
|
||||||
|
|
||||||
@@ -16,7 +17,8 @@ namespace IBKRTrader.Core.Trading.Ibkr;
|
|||||||
/// die sichere Richtung, wenn der Broker nicht erreichbar ist.
|
/// die sichere Richtung, wenn der Broker nicht erreichbar ist.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class IbkrBrokerClient
|
public sealed class IbkrBrokerClient
|
||||||
: IBrokerClient, IBrokerPortfolioReader, IBrokerOrderBook, IOptionMarketData, IQuoteSource, IDisposable
|
: IBrokerClient, IBrokerPortfolioReader, IBrokerOrderBook, IOptionMarketData, IQuoteSource,
|
||||||
|
IMarketCalendar, IDisposable
|
||||||
{
|
{
|
||||||
private const string LogModule = "IBKR";
|
private const string LogModule = "IBKR";
|
||||||
|
|
||||||
@@ -25,6 +27,10 @@ public sealed class IbkrBrokerClient
|
|||||||
private readonly TimeSpan _requestTimeout;
|
private readonly TimeSpan _requestTimeout;
|
||||||
private readonly TimeSpan _orderTimeout;
|
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)
|
public IbkrBrokerClient(SettingsService settings, LoggingService logger)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -112,6 +118,40 @@ public sealed class IbkrBrokerClient
|
|||||||
return await _connection!.RequestExecutionsAsync(since, _requestTimeout, ct).ConfigureAwait(false);
|
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)
|
public async Task<IReadOnlyList<BrokerOrder>> GetOpenOrdersAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Array.Empty<BrokerOrder>();
|
if (!await IsReadyAsync(ct).ConfigureAwait(false)) return Array.Empty<BrokerOrder>();
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
|
|||||||
private bool _zoneWarningIssued;
|
private bool _zoneWarningIssued;
|
||||||
private volatile bool _ready;
|
private volatile bool _ready;
|
||||||
private volatile bool _disposed;
|
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 _nextRequestId = 1000;
|
||||||
private int _nextOrderId = -1;
|
private int _nextOrderId = -1;
|
||||||
private string? _account;
|
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()
|
private void StartReader()
|
||||||
{
|
{
|
||||||
|
var generation = Interlocked.Increment(ref _readerGeneration);
|
||||||
var reader = new EReader(_socket, _signal);
|
var reader = new EReader(_socket, _signal);
|
||||||
reader.Start();
|
reader.Start();
|
||||||
|
|
||||||
new Thread(() =>
|
new Thread(() =>
|
||||||
{
|
{
|
||||||
while (_socket.IsConnected())
|
while (_socket.IsConnected() && Volatile.Read(ref _readerGeneration) == generation)
|
||||||
{
|
{
|
||||||
_signal.waitForSignal();
|
_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
|
try
|
||||||
{
|
{
|
||||||
reader.processMsgs();
|
reader.processMsgs();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
if (!_disposed)
|
if (!_disposed && Volatile.Read(ref _readerGeneration) == generation)
|
||||||
_logger.Warn(LogModule, $"Nachrichten-Reader beendet: {ex.Message}");
|
_logger.Warn(LogModule, $"Nachrichten-Reader beendet: {ex.Message}");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
{ IsBackground = true, Name = "IBKR-Reader" }.Start();
|
{ IsBackground = true, Name = $"IBKR-Reader-{generation}" }.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SafeDisconnect()
|
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>
|
/// <summary>
|
||||||
/// Fragt das Orderbuch des Kontos ab – alle offenen Orders, auch die anderer Client-IDs und die
|
/// Fragt das Orderbuch des Kontos ab – alle offenen Orders, auch die anderer Client-IDs und die
|
||||||
/// aus früheren Sitzungen.
|
/// aus früheren Sitzungen.
|
||||||
@@ -763,8 +838,15 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
|
|||||||
|
|
||||||
public override void contractDetails(int reqId, ContractDetails contractDetails)
|
public override void contractDetails(int reqId, ContractDetails contractDetails)
|
||||||
{
|
{
|
||||||
if (_contracts.TryGetValue(reqId, out var slot))
|
if (!_contracts.TryGetValue(reqId, out var slot)) return;
|
||||||
slot.First ??= contractDetails.Contract;
|
|
||||||
|
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)
|
public override void contractDetailsEnd(int reqId)
|
||||||
@@ -1119,6 +1201,12 @@ internal sealed class IbkrConnection : DefaultEWrapper, IDisposable
|
|||||||
private sealed class ContractSlot : Slot
|
private sealed class ContractSlot : Slot
|
||||||
{
|
{
|
||||||
public Contract? First;
|
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
|
private sealed class OptionChainSlot : Slot
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace IBKRTrader.Core.Trading;
|
||||||
|
|
||||||
|
/// <summary>Ein zusammenhängender Zeitraum, in dem an einer Börse gehandelt wird – immer UTC.</summary>
|
||||||
|
public sealed record MarketSession(DateTime OpenUtc, DateTime CloseUtc)
|
||||||
|
{
|
||||||
|
public bool Contains(DateTime utc) => utc >= OpenUtc && utc < CloseUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Die Handelszeiten eines Instruments, wie der Broker sie meldet.
|
||||||
|
///
|
||||||
|
/// <para><b>Warum das aus dem Broker kommt und nicht aus einer Tabelle im Code.</b> Feiertage,
|
||||||
|
/// halbe Handelstage, Sommerzeitwechsel und Sonderregeln unterscheiden sich je Börse und ändern
|
||||||
|
/// sich jedes Jahr. Eine gepflegte Liste wäre an dem Tag falsch, an dem sie niemand pflegt – und
|
||||||
|
/// das merkt man erst, wenn Orders ins Leere laufen. IBKR weiß es ohnehin.</para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="TimeZoneId">Zeitzone der Börse laut Broker, nur zur Nachvollziehbarkeit.</param>
|
||||||
|
/// <param name="Sessions">Die gemeldeten Handelszeiträume, aufsteigend.</param>
|
||||||
|
public sealed record MarketSchedule(string TimeZoneId, IReadOnlyList<MarketSession> Sessions)
|
||||||
|
{
|
||||||
|
/// <summary>Wird zu diesem Zeitpunkt gehandelt?</summary>
|
||||||
|
public bool IsOpen(DateTime utc) => Sessions.Any(s => s.Contains(utc));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nächster Handelsbeginn ab <paramref name="utc"/>; <c>null</c>, wenn der gemeldete Zeitraum
|
||||||
|
/// keinen mehr enthält. Der Broker liefert nur wenige Tage im Voraus – „kein nächster Beginn"
|
||||||
|
/// heißt deshalb <b>nicht</b> „nie wieder".
|
||||||
|
/// </summary>
|
||||||
|
public DateTime? NextOpenUtc(DateTime utc) =>
|
||||||
|
Sessions.Where(s => s.OpenUtc > utc)
|
||||||
|
.Select(s => (DateTime?)s.OpenUtc)
|
||||||
|
.DefaultIfEmpty(null)
|
||||||
|
.Min();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liest die Handelszeiten-Zeichenketten der TWS-API.
|
||||||
|
///
|
||||||
|
/// <para>Reine Rechnung, damit die Formate ohne Broker prüfbar sind. TWS liefert zwei Schreibweisen,
|
||||||
|
/// je nach Alter des Kontrakts und der API-Fassung:</para>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><c>20260907:0900-20260907:1730;20260908:CLOSED</c> – Datum auf beiden Seiten</item>
|
||||||
|
/// <item><c>20260907:0900-1730,1900-2200;20260908:CLOSED</c> – nur Uhrzeiten, Datum vom Segment</item>
|
||||||
|
/// </list>
|
||||||
|
/// <para>Beide werden gelesen. Was sich nicht lesen lässt, wird <b>übersprungen</b> und nicht
|
||||||
|
/// geraten: Ein falsch gedeuteter Zeitraum ist schlimmer als ein fehlender, weil er Handel zu einer
|
||||||
|
/// Zeit erlaubt, zu der die Börse zu ist.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static class TradingHours
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Liest die Zeichenkette. <c>null</c>, wenn die Zeitzone unbekannt ist oder nichts Lesbares
|
||||||
|
/// übrig bleibt – der Aufrufer muss diesen Fall als „unbekannt" behandeln, nicht als „offen".
|
||||||
|
/// </summary>
|
||||||
|
public static MarketSchedule? Parse(string? hours, string? timeZoneId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(hours)) return null;
|
||||||
|
|
||||||
|
var zone = ResolveZone(timeZoneId);
|
||||||
|
if (zone is null) return null;
|
||||||
|
|
||||||
|
var sessions = new List<MarketSession>();
|
||||||
|
|
||||||
|
foreach (var segment in hours.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var colon = segment.IndexOf(':');
|
||||||
|
if (colon <= 0) continue;
|
||||||
|
|
||||||
|
var dayText = segment[..colon].Trim();
|
||||||
|
var body = segment[(colon + 1)..].Trim();
|
||||||
|
|
||||||
|
if (body.Equals("CLOSED", StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
if (!TryDay(dayText, out var day)) continue;
|
||||||
|
|
||||||
|
foreach (var range in body.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
if (TryRange(range, day, zone, out var session)) sessions.Add(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessions.Count == 0) return null;
|
||||||
|
|
||||||
|
sessions.Sort((a, b) => a.OpenUtc.CompareTo(b.OpenUtc));
|
||||||
|
return new MarketSchedule(zone.Id, sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sucht die Zeitzone des Brokers. TWS meldet IANA-Namen ("US/Eastern", "Europe/Berlin") und
|
||||||
|
/// gelegentlich Kürzel ("MET", "GMT"). Findet sich keine, gibt es <b>keine</b> Ersatzzone:
|
||||||
|
/// Mit der falschen Zone gerechnete Handelszeiten sind schlimmer als gar keine.
|
||||||
|
/// </summary>
|
||||||
|
private static TimeZoneInfo? ResolveZone(string? id)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(id)) return null;
|
||||||
|
|
||||||
|
try { return TimeZoneInfo.FindSystemTimeZoneById(id.Trim()); }
|
||||||
|
catch (TimeZoneNotFoundException) { }
|
||||||
|
catch (InvalidTimeZoneException) { }
|
||||||
|
|
||||||
|
// Ein paar Kuerzel, die TWS verwendet und die als Zonen-Id nicht ueberall existieren.
|
||||||
|
var fallback = id.Trim().ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"MET" => "Europe/Berlin",
|
||||||
|
"CET" => "Europe/Berlin",
|
||||||
|
"EST5EDT" => "America/New_York",
|
||||||
|
"CST6CDT" => "America/Chicago",
|
||||||
|
"MST7MDT" => "America/Denver",
|
||||||
|
"PST8PDT" => "America/Los_Angeles",
|
||||||
|
"JST" => "Asia/Tokyo",
|
||||||
|
"GMT" or "UTC" => "UTC",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fallback is null) return null;
|
||||||
|
|
||||||
|
try { return TimeZoneInfo.FindSystemTimeZoneById(fallback); }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryDay(string text, out DateTime day) =>
|
||||||
|
DateTime.TryParseExact(text, "yyyyMMdd", CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.None, out day);
|
||||||
|
|
||||||
|
private static bool TryRange(string range, DateTime day, TimeZoneInfo zone, out MarketSession session)
|
||||||
|
{
|
||||||
|
session = null!;
|
||||||
|
|
||||||
|
var dash = range.IndexOf('-');
|
||||||
|
if (dash <= 0) return false;
|
||||||
|
|
||||||
|
if (!TryPoint(range[..dash], day, zone, out var open)) return false;
|
||||||
|
if (!TryPoint(range[(dash + 1)..], day, zone, out var close)) return false;
|
||||||
|
|
||||||
|
// Ein Zeitraum ueber Mitternacht (etwa Futures) meldet TWS in der kurzen Schreibweise ohne
|
||||||
|
// Datum. Ohne diese Korrektur laege das Ende vor dem Anfang und der Zeitraum fiele weg.
|
||||||
|
if (close <= open) close = close.AddDays(1);
|
||||||
|
|
||||||
|
session = new MarketSession(open, close);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Liest "HHmm" (Datum aus dem Segment) oder "yyyyMMdd:HHmm" und rechnet nach UTC.</summary>
|
||||||
|
private static bool TryPoint(string text, DateTime day, TimeZoneInfo zone, out DateTime utc)
|
||||||
|
{
|
||||||
|
utc = default;
|
||||||
|
var value = text.Trim();
|
||||||
|
|
||||||
|
var colon = value.IndexOf(':');
|
||||||
|
if (colon > 0)
|
||||||
|
{
|
||||||
|
if (!TryDay(value[..colon], out day)) return false;
|
||||||
|
value = value[(colon + 1)..].Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!DateTime.TryParseExact(value, "HHmm", CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.None, out var time))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var local = DateTime.SpecifyKind(day.Date.Add(time.TimeOfDay), DateTimeKind.Unspecified);
|
||||||
|
|
||||||
|
// Waehrend der Zeitumstellung kann ein lokaler Zeitpunkt doppelt oder gar nicht existieren.
|
||||||
|
// TimeZoneInfo wirft dann nicht, sondern rechnet - ausser bei "gibt es nicht", deshalb der
|
||||||
|
// Fang. Ein uebersprungener Zeitraum ist die sichere Richtung.
|
||||||
|
try { utc = TimeZoneInfo.ConvertTimeToUtc(local, zone); return true; }
|
||||||
|
catch (ArgumentException) { return false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auskunft über die Handelszeiten eines Instruments.
|
||||||
|
///
|
||||||
|
/// <para>Eigener Seam neben <see cref="IBrokerClient"/>: Wer wissen will, ob eine Börse offen ist,
|
||||||
|
/// braucht dafür nicht die Fähigkeit, Orders auszulösen (Kapitalmodell §2 Regel 4) – dieselbe
|
||||||
|
/// Trennung wie bei <see cref="IQuoteSource"/>.</para>
|
||||||
|
/// </summary>
|
||||||
|
public interface IMarketCalendar
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Handelszeiten des Instruments; <c>null</c>, wenn sie sich nicht ermitteln lassen.
|
||||||
|
///
|
||||||
|
/// <para><b>„Unbekannt" ist nicht „offen".</b> Ein Aufrufer, der aus <c>null</c> auf einen
|
||||||
|
/// offenen Markt schließt, handelt bei jeder Verbindungsstörung ins Geschlossene.</para>
|
||||||
|
/// </summary>
|
||||||
|
Task<MarketSchedule?> GetScheduleAsync(InstrumentRef instrument, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ namespace IBKRTrader.Core.Trading;
|
|||||||
/// Paper-Gateway verifiziert ist. So kann keine Order versehentlich rausgehen.
|
/// Paper-Gateway verifiziert ist. So kann keine Order versehentlich rausgehen.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NullBrokerClient
|
public sealed class NullBrokerClient
|
||||||
: IBrokerClient, IBrokerPortfolioReader, IBrokerOrderBook, IOptionMarketData, IQuoteSource
|
: IBrokerClient, IBrokerPortfolioReader, IBrokerOrderBook, IOptionMarketData, IQuoteSource,
|
||||||
|
IMarketCalendar
|
||||||
{
|
{
|
||||||
private readonly LoggingService _logger;
|
private readonly LoggingService _logger;
|
||||||
|
|
||||||
@@ -28,6 +29,11 @@ public sealed class NullBrokerClient
|
|||||||
public Task<bool> CancelOrderAsync(int orderId, CancellationToken ct = default)
|
public Task<bool> CancelOrderAsync(int orderId, CancellationToken ct = default)
|
||||||
=> Task.FromResult(false);
|
=> 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)
|
public Task<Quote?> GetQuoteAsync(InstrumentRef instrument, CancellationToken ct = default)
|
||||||
=> Task.FromResult<Quote?>(null);
|
=> Task.FromResult<Quote?>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ public static class AppHostBuilder
|
|||||||
services.AddSingleton<IBrokerClient>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
services.AddSingleton<IBrokerClient>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
||||||
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
||||||
services.AddSingleton<IBrokerOrderBook>(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<IOptionMarketData>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
||||||
services.AddSingleton<IQuoteSource>(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<IBrokerClient>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||||
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<NullBrokerClient>());
|
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||||
services.AddSingleton<IBrokerOrderBook>(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<IOptionMarketData>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||||
services.AddSingleton<IQuoteSource>(sp => sp.GetRequiredService<NullBrokerClient>());
|
services.AddSingleton<IQuoteSource>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,8 +146,14 @@ public sealed class AuthService
|
|||||||
if (ValidateSessionToken(cookieToken) || ValidateToken(cookieToken)) return true;
|
if (ValidateSessionToken(cookieToken) || ValidateToken(cookieToken)) return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Query-Parameter ?token= (wichtig für SSE EventSource)
|
// 3. Query-Parameter ?token= – NUR fuer den SSE-Strom.
|
||||||
if (context.Request.Query.TryGetValue("token", out var queryToken))
|
//
|
||||||
|
// 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;
|
if (ValidateSessionToken(queryToken) || ValidateToken(queryToken)) return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,50 @@ namespace IBKRTrader.Hosting.Web.Endpoints;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class CoreEndpoints
|
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)
|
public static void Map(RouteGroupBuilder api)
|
||||||
{
|
{
|
||||||
// ─── Status & Auth ────────────────────────────────────────────────────────
|
// ─── Status & Auth ────────────────────────────────────────────────────────
|
||||||
@@ -154,23 +198,27 @@ public static class CoreEndpoints
|
|||||||
|
|
||||||
api.MapPost("/positions/reassign", async (ReassignRequest req, PositionManager positions) =>
|
api.MapPost("/positions/reassign", async (ReassignRequest req, PositionManager positions) =>
|
||||||
{
|
{
|
||||||
var instrument = InstrumentRef.ForStock(req.Instrument);
|
if (!TryReadInstrument(req.Instrument, out var instrument, out var problem))
|
||||||
var fromBook = BookId.Parse(req.FromBook);
|
return Results.BadRequest(new { success = false, message = problem });
|
||||||
var toBook = BookId.Parse(req.ToBook);
|
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 });
|
return Results.Ok(new { success = result.Success, message = result.Message });
|
||||||
});
|
});
|
||||||
|
|
||||||
api.MapPost("/positions/lock", async (SetLockRequest req, PositionManager positions) =>
|
api.MapPost("/positions/lock", async (SetLockRequest req, PositionManager positions) =>
|
||||||
{
|
{
|
||||||
var instrument = InstrumentRef.ForStock(req.Instrument);
|
if (!TryReadInstrument(req.Instrument, out var instrument, out var problem))
|
||||||
var book = BookId.Parse(req.Book);
|
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))
|
if (!Enum.TryParse<PositionLock>(req.Level, true, out var level))
|
||||||
return Results.BadRequest(new { success = false, message = "Ungültige Sperrstufe." });
|
return Results.BadRequest(new { success = false, message = "Ungültige Sperrstufe." });
|
||||||
|
|
||||||
await positions.SetLockAsync(book, instrument, level, req.Note).ConfigureAwait(false);
|
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) =>
|
api.MapPost("/positions/rule", async (SetRuleRequest req, PositionManager positions) =>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text.Json;
|
|
||||||
using IBKRTrader.Core.Analytics;
|
|
||||||
using IBKRTrader.Core.Modularity;
|
using IBKRTrader.Core.Modularity;
|
||||||
using IBKRTrader.Core.Time;
|
using IBKRTrader.Core.Settings;
|
||||||
using IBKRTrader.Core.Trading;
|
using IBKRTrader.Core.Trading;
|
||||||
using IBKRTrader.Core.Workers;
|
using IBKRTrader.Core.Workers;
|
||||||
using IBKRTrader.Modules.Accounting.Logic;
|
using IBKRTrader.Modules.Accounting.Logic;
|
||||||
@@ -10,12 +8,15 @@ using IBKRTrader.Modules.Accounting.Persistence;
|
|||||||
using IBKRTrader.Modules.Accounting.Services;
|
using IBKRTrader.Modules.Accounting.Services;
|
||||||
using IBKRTrader.Modules.CongressTrading;
|
using IBKRTrader.Modules.CongressTrading;
|
||||||
using IBKRTrader.Modules.CongressTrading.Database;
|
using IBKRTrader.Modules.CongressTrading.Database;
|
||||||
using IBKRTrader.Modules.Supervisor.Agent;
|
using IBKRTrader.Modules.DebugTrading.Logic;
|
||||||
using IBKRTrader.Modules.Supervisor.Persistence;
|
using IBKRTrader.Modules.DebugTrading.Persistence.Ef;
|
||||||
using IBKRTrader.Modules.Supervisor.Services;
|
using IBKRTrader.Modules.OptionsWheel;
|
||||||
|
using IBKRTrader.Modules.OptionsWheel.Persistence.Ef;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace IBKRTrader.Hosting.Web.Endpoints;
|
namespace IBKRTrader.Hosting.Web.Endpoints;
|
||||||
@@ -38,8 +39,11 @@ public static class ModuleEndpoints
|
|||||||
if (modules.Any(m => string.Equals(m.Name, "Accounting", StringComparison.OrdinalIgnoreCase)))
|
if (modules.Any(m => string.Equals(m.Name, "Accounting", StringComparison.OrdinalIgnoreCase)))
|
||||||
MapAccounting(api);
|
MapAccounting(api);
|
||||||
|
|
||||||
if (modules.Any(m => string.Equals(m.Name, "Supervisor", StringComparison.OrdinalIgnoreCase)))
|
if (modules.Any(m => string.Equals(m.Name, "OptionsWheel", StringComparison.OrdinalIgnoreCase)))
|
||||||
MapSupervisor(api);
|
MapOptionsWheel(api, sp);
|
||||||
|
|
||||||
|
if (modules.Any(m => string.Equals(m.Name, "DebugTrading", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
MapDebugTrading(api, sp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── CongressTrading ──────────────────────────────────────────────────────────
|
// ─── CongressTrading ──────────────────────────────────────────────────────────
|
||||||
@@ -47,13 +51,25 @@ public static class ModuleEndpoints
|
|||||||
{
|
{
|
||||||
var group = api.MapGroup("/modules/congresstrading");
|
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 trades = await repo.GetTradeCountAsync().ConfigureAwait(false);
|
||||||
var members = await repo.GetMemberCountAsync().ConfigureAwait(false);
|
var members = await repo.GetMemberCountAsync().ConfigureAwait(false);
|
||||||
var positions = await portfolio.GetPositionsAsync(CongressTradingModule.LogTag).ConfigureAwait(false);
|
var positions = await portfolio.GetPositionsAsync(CongressTradingModule.LogTag).ConfigureAwait(false);
|
||||||
|
|
||||||
return new
|
return Results.Ok(new
|
||||||
{
|
{
|
||||||
trades,
|
trades,
|
||||||
members,
|
members,
|
||||||
@@ -64,11 +80,15 @@ public static class ModuleEndpoints
|
|||||||
avgPrice = p.AvgPrice,
|
avgPrice = p.AvgPrice,
|
||||||
notional = p.Notional
|
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
|
try
|
||||||
{
|
{
|
||||||
await engine.TriggerWorkerAsync("CT-ScrapeWorker").ConfigureAwait(false);
|
await engine.TriggerWorkerAsync("CT-ScrapeWorker").ConfigureAwait(false);
|
||||||
@@ -86,18 +106,40 @@ public static class ModuleEndpoints
|
|||||||
{
|
{
|
||||||
var group = api.MapGroup("/modules/accounting");
|
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", (
|
group.MapGet("/overview", (
|
||||||
AccountingReportService report,
|
IServiceProvider sp,
|
||||||
string? account,
|
string? account,
|
||||||
string? currency,
|
string? currency,
|
||||||
DateTime? from,
|
DateTime? from,
|
||||||
DateTime? to) =>
|
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 fromDate = from ?? DateTime.Today.AddMonths(-1);
|
||||||
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
|
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
|
||||||
var curr = string.IsNullOrWhiteSpace(currency) ? "USD" : currency;
|
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);
|
decimal M(decimal v) => Math.Round(v * view.Factor, 2);
|
||||||
|
|
||||||
return new
|
return Results.Ok(new
|
||||||
{
|
{
|
||||||
currencyCode = view.Code,
|
currencyCode = view.Code,
|
||||||
currencyNote = view.Note,
|
currencyNote = view.Note,
|
||||||
@@ -134,21 +176,24 @@ public static class ModuleEndpoints
|
|||||||
netTradingResult = M(m.NetTradingResult),
|
netTradingResult = M(m.NetTradingResult),
|
||||||
closingBalance = M(m.ClosingBalance)
|
closingBalance = M(m.ClosingBalance)
|
||||||
})
|
})
|
||||||
};
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapGet("/ledger", (
|
group.MapGet("/ledger", (
|
||||||
ILedgerRepository ledger,
|
IServiceProvider sp,
|
||||||
string? account,
|
string? account,
|
||||||
DateTime? from,
|
DateTime? from,
|
||||||
DateTime? to,
|
DateTime? to,
|
||||||
int? limit) =>
|
int? limit) =>
|
||||||
{
|
{
|
||||||
|
var ledger = sp.GetService<ILedgerRepository>();
|
||||||
|
if (ledger == null) return Results.Ok(Array.Empty<object>());
|
||||||
|
|
||||||
var fromDate = from ?? DateTime.Today.AddMonths(-1);
|
var fromDate = from ?? DateTime.Today.AddMonths(-1);
|
||||||
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
|
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
|
||||||
var acc = string.IsNullOrWhiteSpace(account) || account == "(alle)" ? null : account;
|
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),
|
timestamp = e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||||
accountId = e.AccountId,
|
accountId = e.AccountId,
|
||||||
@@ -162,13 +207,16 @@ public static class ModuleEndpoints
|
|||||||
feeBase = e.FeeBase,
|
feeBase = e.FeeBase,
|
||||||
netBase = e.NetBase,
|
netBase = e.NetBase,
|
||||||
transactionId = e.TransactionId
|
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;
|
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,
|
accountId = r.AccountId,
|
||||||
startedAt = r.StartedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
startedAt = r.StartedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
|
||||||
@@ -179,11 +227,15 @@ public static class ModuleEndpoints
|
|||||||
success = r.Success,
|
success = r.Success,
|
||||||
balanceDeltaBase = r.BalanceDeltaBase,
|
balanceDeltaBase = r.BalanceDeltaBase,
|
||||||
message = r.Message
|
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
|
try
|
||||||
{
|
{
|
||||||
await ingest.IngestAllAsync(backfill, CancellationToken.None).ConfigureAwait(false);
|
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 fromDate = from ?? DateTime.Today.AddMonths(-1);
|
||||||
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
|
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
|
||||||
var acc = string.IsNullOrWhiteSpace(account) || account == "(alle)" ? null : account;
|
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");
|
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 fromDate = from ?? DateTime.Today.AddMonths(-1);
|
||||||
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
|
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
|
||||||
var acc = string.IsNullOrWhiteSpace(account) || account == "(alle)" ? null : account;
|
var acc = string.IsNullOrWhiteSpace(account) || account == "(alle)" ? null : account;
|
||||||
@@ -218,13 +276,16 @@ public static class ModuleEndpoints
|
|||||||
});
|
});
|
||||||
|
|
||||||
group.MapGet("/export/statement.pdf", (
|
group.MapGet("/export/statement.pdf", (
|
||||||
AccountingReportService report,
|
IServiceProvider sp,
|
||||||
ILedgerRepository ledger,
|
|
||||||
string? account,
|
string? account,
|
||||||
string? currency,
|
string? currency,
|
||||||
DateTime? from,
|
DateTime? from,
|
||||||
DateTime? to) =>
|
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 fromDate = from ?? DateTime.Today.AddMonths(-1);
|
||||||
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
|
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
|
||||||
var acc = string.IsNullOrWhiteSpace(account) || account == "(alle)" ? null : account;
|
var acc = string.IsNullOrWhiteSpace(account) || account == "(alle)" ? null : account;
|
||||||
@@ -240,90 +301,312 @@ public static class ModuleEndpoints
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Supervisor ───────────────────────────────────────────────────────────────
|
// ─── OptionsWheel ─────────────────────────────────────────────────────────────
|
||||||
private static void MapSupervisor(RouteGroupBuilder api)
|
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("/status", async (
|
||||||
|
IConfiguration config,
|
||||||
group.MapGet("/signals", (DossierService dossiers, int? limit) =>
|
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) =>
|
int underlyingsCount = 0;
|
||||||
{
|
int activeCyclesCount = 0;
|
||||||
var dossier = dossiers.BuildForSignal(signalId);
|
int openLegsCount = 0;
|
||||||
var markdown = DossierBuilder.ToMarkdown(dossier);
|
|
||||||
return new { signalId, markdown };
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapGet("/reports", (ISupervisorReportRepository reports, int? limit) =>
|
var dbf = requestSp.GetService<IDbContextFactory<OptionsWheelDbContext>>();
|
||||||
|
if (dbf != null)
|
||||||
{
|
{
|
||||||
return reports.GetRecent(limit ?? 100).Select(r => new
|
|
||||||
{
|
|
||||||
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}"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapGet("/info", () =>
|
|
||||||
{
|
|
||||||
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 ?? "");
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await agent.AskAsync(req.Question, profile: profile).ConfigureAwait(false);
|
await using var db = await dbf.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
|
underlyingsCount = await db.Underlyings.CountAsync().ConfigureAwait(false);
|
||||||
reports.Insert(new SupervisorReport
|
activeCyclesCount = await db.Cycles.CountAsync(c => c.EndedAt == null).ConfigureAwait(false);
|
||||||
{
|
openLegsCount = await db.Legs.CountAsync(l => l.Status == "Open").ConfigureAwait(false);
|
||||||
Profile = profile.Name,
|
}
|
||||||
Model = SupervisorAgent.DefaultModel,
|
catch { /* DB-Zugriff optional falls Tabellen noch nicht angelegt */ }
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
return Results.Ok(new
|
return Results.Ok(new
|
||||||
{
|
{
|
||||||
success = true,
|
enabled = settings.Enabled,
|
||||||
answer = result.Answer,
|
noNewPositions = settings.NoNewPositions,
|
||||||
toolCalls = result.ToolInvocations.Select(i => new { tool = i.Tool, arguments = i.Arguments }),
|
scanIntervalMinutes = settings.ScanIntervalMinutes,
|
||||||
toolCallCount = result.ToolInvocations.Count
|
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("/underlyings", 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.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>());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/cycles", 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.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
|
||||||
|
{
|
||||||
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return Results.BadRequest(new { success = false, message = ex.Message });
|
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.Persistence;
|
||||||
using IBKRTrader.Modules.Accounting.Services;
|
using IBKRTrader.Modules.Accounting.Services;
|
||||||
using IBKRTrader.Modules.CongressTrading.Database;
|
using IBKRTrader.Modules.CongressTrading.Database;
|
||||||
using IBKRTrader.Modules.Supervisor.Agent;
|
using IBKRTrader.Modules.DebugTrading.Persistence.Ef;
|
||||||
using IBKRTrader.Modules.Supervisor.Persistence;
|
using IBKRTrader.Modules.OptionsWheel.Persistence.Ef;
|
||||||
using IBKRTrader.Modules.Supervisor.Services;
|
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
@@ -117,9 +116,8 @@ public sealed class WebserverWorker : WorkerBase, IDisposable
|
|||||||
ForwardOptional<IIngestRunRepository>(builder.Services);
|
ForwardOptional<IIngestRunRepository>(builder.Services);
|
||||||
ForwardOptional<AccountingReportService>(builder.Services);
|
ForwardOptional<AccountingReportService>(builder.Services);
|
||||||
ForwardOptional<AccountingIngestService>(builder.Services);
|
ForwardOptional<AccountingIngestService>(builder.Services);
|
||||||
ForwardOptional<SupervisorAgent>(builder.Services);
|
ForwardOptional<IDbContextFactory<OptionsWheelDbContext>>(builder.Services);
|
||||||
ForwardOptional<DossierService>(builder.Services);
|
ForwardOptional<IDbContextFactory<DebugTradingDbContext>>(builder.Services);
|
||||||
ForwardOptional<ISupervisorReportRepository>(builder.Services);
|
|
||||||
|
|
||||||
_app = builder.Build();
|
_app = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,52 @@
|
|||||||
--color-danger: #dc2626;
|
--color-danger: #dc2626;
|
||||||
--color-danger-bg: #fee2e2;
|
--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-family: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
|
||||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
--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;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -42,12 +84,13 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── App Header & Navigation ─────────────────────────────────────────── */
|
/* ─── App Header & Navigation ─────────────────────────────────────────── */
|
||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
background: #ffffff;
|
background: var(--bg-card);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -56,12 +99,14 @@ body {
|
|||||||
height: 48px;
|
height: 48px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-title {
|
.brand-title {
|
||||||
@@ -93,24 +138,62 @@ body {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Navigation Tabs & Grouping ─────────────────────────────────────── */
|
||||||
|
|
||||||
.nav-tabs {
|
.nav-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
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 {
|
.nav-tab {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 8px 14px;
|
padding: 6px 10px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 2px solid transparent;
|
border-radius: 4px;
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-tab:hover {
|
.nav-tab:hover {
|
||||||
@@ -121,14 +204,81 @@ body {
|
|||||||
.nav-tab.active {
|
.nav-tab.active {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-bottom-color: var(--color-primary);
|
background: var(--bg-card);
|
||||||
background: transparent;
|
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 {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── App Body Layout ─────────────────────────────────────────────────── */
|
/* ─── App Body Layout ─────────────────────────────────────────────────── */
|
||||||
@@ -177,7 +327,7 @@ body {
|
|||||||
/* ─── Buttons & Controls ──────────────────────────────────────────────── */
|
/* ─── Buttons & Controls ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
background: #ffffff;
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 5px 12px;
|
padding: 5px 12px;
|
||||||
@@ -194,13 +344,18 @@ body {
|
|||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
border-color: #bbb;
|
border-color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:active {
|
.btn:active {
|
||||||
background: var(--bg-selected);
|
background: var(--bg-selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
border-color: var(--color-primary-hover);
|
border-color: var(--color-primary-hover);
|
||||||
@@ -209,7 +364,7 @@ body {
|
|||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
background: var(--color-primary-hover);
|
background: var(--color-primary-hover);
|
||||||
border-color: #1e40af;
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
@@ -222,20 +377,26 @@ body {
|
|||||||
background: #b91c1c;
|
background: #b91c1c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input, select {
|
.theme-toggle-btn {
|
||||||
background: #ffffff;
|
padding: 4px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input, select, textarea {
|
||||||
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 4px 8px;
|
padding: 5px 8px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus, select:focus {
|
.input:focus, select:focus, textarea:focus {
|
||||||
border-color: var(--color-primary);
|
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 {
|
.checkbox-label {
|
||||||
@@ -255,17 +416,17 @@ body {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: #ffffff;
|
background: var(--bg-card);
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kpi-card {
|
.kpi-card {
|
||||||
background: #f5f5f5;
|
background: var(--bg-toolbar);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
min-width: 160px;
|
min-width: 150px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -291,7 +452,7 @@ body {
|
|||||||
.table-container {
|
.table-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: #ffffff;
|
background: var(--bg-card);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,10 +464,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.datagrid th {
|
.datagrid th {
|
||||||
background: #fafafa;
|
background: var(--bg-toolbar);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 6px 10px;
|
padding: 7px 10px;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -316,8 +477,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.datagrid td {
|
.datagrid td {
|
||||||
padding: 5px 10px;
|
padding: 6px 10px;
|
||||||
border-bottom: 1px solid #eeeeee;
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-feature-settings: "tnum";
|
font-feature-settings: "tnum";
|
||||||
@@ -325,7 +486,7 @@ body {
|
|||||||
|
|
||||||
.datagrid tbody tr {
|
.datagrid tbody tr {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
height: 26px;
|
height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.datagrid tbody tr:hover {
|
.datagrid tbody tr:hover {
|
||||||
@@ -333,8 +494,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.datagrid tbody tr.selected {
|
.datagrid tbody tr.selected {
|
||||||
background: #e0e7ff !important;
|
background: var(--bg-selected) !important;
|
||||||
color: #1e3a8a;
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.datagrid .text-right {
|
.datagrid .text-right {
|
||||||
@@ -354,16 +515,16 @@ body {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.badge-ok { background: #dcfce7; color: #15803d; }
|
.badge-ok { background: var(--color-success-bg); color: var(--color-success); }
|
||||||
.badge-warn { background: #fef3c7; color: #b45309; }
|
.badge-warn { background: var(--color-warning-bg); color: var(--color-warning); }
|
||||||
.badge-error { background: #fee2e2; color: #b91c1c; }
|
.badge-error { background: var(--color-danger-bg); color: var(--color-danger); }
|
||||||
|
|
||||||
/* ─── Log Viewer ──────────────────────────────────────────────────────── */
|
/* ─── Log Viewer ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.log-scroller {
|
.log-scroller {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: #1e1e1e;
|
background: #101014;
|
||||||
color: #d4d4d4;
|
color: #d4d4d8;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -376,9 +537,9 @@ body {
|
|||||||
.log-line {
|
.log-line {
|
||||||
padding: 1px 0;
|
padding: 1px 0;
|
||||||
}
|
}
|
||||||
.log-info { color: #96d296; }
|
.log-info { color: #86efac; }
|
||||||
.log-warn { color: #ffbe3c; }
|
.log-warn { color: #fde047; }
|
||||||
.log-error { color: #ff5050; font-weight: 600; }
|
.log-error { color: #f87171; font-weight: 600; }
|
||||||
|
|
||||||
/* ─── Settings Tree Form ──────────────────────────────────────────────── */
|
/* ─── Settings Tree Form ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -386,10 +547,11 @@ body {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
|
background: var(--bg-app);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section {
|
.settings-section {
|
||||||
background: #ffffff;
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
@@ -397,9 +559,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-section-header {
|
.settings-section-header {
|
||||||
background: #fafafa;
|
background: var(--bg-toolbar);
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -408,10 +571,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-section-body {
|
.settings-section-body {
|
||||||
padding: 10px 14px;
|
padding: 12px 16px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 240px 1fr;
|
grid-template-columns: 260px 1fr;
|
||||||
row-gap: 8px;
|
row-gap: 10px;
|
||||||
column-gap: 16px;
|
column-gap: 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -423,7 +586,7 @@ body {
|
|||||||
.settings-desc {
|
.settings-desc {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-top: 1px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Statusbar ───────────────────────────────────────────────────────── */
|
/* ─── Statusbar ───────────────────────────────────────────────────────── */
|
||||||
@@ -447,18 +610,18 @@ body {
|
|||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.45);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(3px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
background: #ffffff;
|
background: var(--bg-card);
|
||||||
border-radius: 8px;
|
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%;
|
width: 100%;
|
||||||
max-width: 380px;
|
max-width: 380px;
|
||||||
padding: 28px;
|
padding: 28px;
|
||||||
@@ -470,6 +633,7 @@ body {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-subtitle {
|
.login-subtitle {
|
||||||
@@ -488,6 +652,7 @@ body {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input {
|
.form-input {
|
||||||
@@ -496,12 +661,14 @@ body {
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
background: var(--bg-toolbar);
|
||||||
|
color: var(--text-main);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-input:focus {
|
.form-input:focus {
|
||||||
border-color: var(--color-primary);
|
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 {
|
.login-error {
|
||||||
|
|||||||
@@ -5,6 +5,15 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>IBKRTrader</title>
|
<title>IBKRTrader</title>
|
||||||
<link rel="stylesheet" href="css/app.css" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -15,19 +24,57 @@
|
|||||||
<span id="app-mode-badge" class="brand-badge badge-paper">PAPER</span>
|
<span id="app-mode-badge" class="brand-badge badge-paper">PAPER</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Gruppierte Navigation: Core-Funktionen vs. Trading- & Spezial-Module -->
|
||||||
<nav class="nav-tabs" id="main-nav">
|
<nav class="nav-tabs" id="main-nav">
|
||||||
<button class="nav-tab active" data-view="dashboard">Dashboard</button>
|
<!-- CORE BEREICH -->
|
||||||
<button class="nav-tab" data-view="workers">Worker</button>
|
<div class="nav-group">
|
||||||
<button class="nav-tab" data-view="positions">Positionen</button>
|
<span class="nav-group-label">CORE</span>
|
||||||
<button class="nav-tab" data-view="orders">Orders</button>
|
<button class="nav-tab active" data-view="dashboard" title="Core: Dashboard & Übersicht">
|
||||||
<button class="nav-tab" data-view="congress">Congress</button>
|
Dashboard
|
||||||
<button class="nav-tab" data-view="accounting">Accounting</button>
|
</button>
|
||||||
<button class="nav-tab" data-view="supervisor">Supervisor</button>
|
<button class="nav-tab" data-view="workers" title="Core: Worker & Hintergrunddienste">
|
||||||
<button class="nav-tab" data-view="logs">Logs</button>
|
Worker
|
||||||
<button class="nav-tab" data-view="settings">Einstellungen</button>
|
</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>
|
</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>
|
<button id="logout-btn" class="btn" style="padding: 4px 10px; font-size: 11px;">🔒 Abmelden</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import { WorkersView } from './views/workers.js';
|
|||||||
import { PositionsView } from './views/positions.js';
|
import { PositionsView } from './views/positions.js';
|
||||||
import { OrdersView } from './views/orders.js';
|
import { OrdersView } from './views/orders.js';
|
||||||
import { CongressTradingView } from './views/congress.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 { AccountingView } from './views/accounting.js';
|
||||||
import { SupervisorView } from './views/supervisor.js';
|
|
||||||
import { LogsView } from './views/logs.js';
|
import { LogsView } from './views/logs.js';
|
||||||
import { SettingsView } from './views/settings.js';
|
import { SettingsView } from './views/settings.js';
|
||||||
|
|
||||||
@@ -18,25 +19,31 @@ class App {
|
|||||||
this.statusMsgEl = document.getElementById('status-bar-message');
|
this.statusMsgEl = document.getElementById('status-bar-message');
|
||||||
this.clockEl = document.getElementById('status-bar-clock');
|
this.clockEl = document.getElementById('status-bar-clock');
|
||||||
this.modeBadgeEl = document.getElementById('app-mode-badge');
|
this.modeBadgeEl = document.getElementById('app-mode-badge');
|
||||||
|
this.themeToggleBtn = document.getElementById('theme-toggle-btn');
|
||||||
this.currentView = null;
|
this.currentView = null;
|
||||||
this.currentViewName = null;
|
this.currentViewName = null;
|
||||||
|
|
||||||
this.views = {
|
this.views = {
|
||||||
|
// Core-Funktionen
|
||||||
dashboard: DashboardView,
|
dashboard: DashboardView,
|
||||||
workers: WorkersView,
|
workers: WorkersView,
|
||||||
positions: PositionsView,
|
positions: PositionsView,
|
||||||
orders: OrdersView,
|
orders: OrdersView,
|
||||||
congress: CongressTradingView,
|
|
||||||
accounting: AccountingView,
|
|
||||||
supervisor: SupervisorView,
|
|
||||||
logs: LogsView,
|
logs: LogsView,
|
||||||
settings: SettingsView
|
settings: SettingsView,
|
||||||
|
|
||||||
|
// Module
|
||||||
|
congress: CongressTradingView,
|
||||||
|
optionswheel: OptionsWheelView,
|
||||||
|
debugtrading: DebugTradingView,
|
||||||
|
accounting: AccountingView
|
||||||
};
|
};
|
||||||
|
|
||||||
this.setStatus = this.setStatus.bind(this);
|
this.setStatus = this.setStatus.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
this.initTheme();
|
||||||
this.startClock();
|
this.startClock();
|
||||||
this.wireNav();
|
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() {
|
startClock() {
|
||||||
const updateClock = () => {
|
const updateClock = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ export class AccountingView {
|
|||||||
this.container.innerHTML = `
|
this.container.innerHTML = `
|
||||||
<div class="toolbar" style="flex-wrap: wrap; gap: 8px;">
|
<div class="toolbar" style="flex-wrap: wrap; gap: 8px;">
|
||||||
<div class="toolbar-group" 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;" />
|
Von: <input type="date" id="acc-from" class="input" value="${monthAgo}" style="width: 125px; padding: 3px 6px;" />
|
||||||
</label>
|
</label>
|
||||||
<label style="font-weight: 500; font-size: 12px; display: flex; align-items: center; gap: 4px;">
|
<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 = `
|
this.container.innerHTML = `
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-group">
|
<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>
|
<button id="ct-scrape-btn" class="btn btn-primary" title="Löst den CT-Scrape-Worker sofort aus.">⚡ Scrape jetzt</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="ct-status-text" style="color: var(--text-muted); font-size: 11px;"></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 = `
|
this.container.innerHTML = `
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-group">
|
<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>
|
<span id="dash-mode-text" style="font-weight: 500; margin-left: 8px;"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="dash-status-text" style="color: var(--text-muted); font-size: 11px;"></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 & 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 & 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 = `
|
this.container.innerHTML = `
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-group">
|
<div class="toolbar-group">
|
||||||
<input type="text" id="log-filter-input" class="input" placeholder="Filter (z. B. CT, Core, Warn) …" style="width: 240px;" />
|
<span class="view-tag tag-core">CORE</span>
|
||||||
<button id="log-clear-btn" class="btn">Geleert</button>
|
<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>
|
<button id="log-copy-btn" class="btn">📋 Kopieren</button>
|
||||||
<label class="checkbox-label" style="margin-left: 8px;">
|
<label class="checkbox-label" style="margin-left: 8px;">
|
||||||
<input type="checkbox" id="log-autoscroll-chk" checked />
|
<input type="checkbox" id="log-autoscroll-chk" checked />
|
||||||
@@ -60,8 +62,9 @@ export class LogsView {
|
|||||||
this.eventSource.close();
|
this.eventSource.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = api.token;
|
// Kein Token in der URL: EventSource sendet das HttpOnly-Cookie ibkr_session von selbst
|
||||||
const url = `/api/logs/stream${token ? `?token=${encodeURIComponent(token)}` : ''}`;
|
// (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) …');
|
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 & 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 & 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 = `
|
this.container.innerHTML = `
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-group">
|
<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 & 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-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>
|
||||||
<div id="ord-status-summary" style="color: var(--text-muted); font-size: 11px;"></div>
|
<div id="ord-status-summary" style="color: var(--text-muted); font-size: 11px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export class PositionsView {
|
|||||||
this.container.innerHTML = `
|
this.container.innerHTML = `
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-group">
|
<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;">
|
<label class="checkbox-label" style="margin-left: 8px;">
|
||||||
<input type="checkbox" id="pos-broker-chk" checked />
|
<input type="checkbox" id="pos-broker-chk" checked />
|
||||||
Broker abfragen (Bestandsvergleich)
|
Broker abfragen (Bestandsvergleich)
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export class SettingsView {
|
|||||||
this.container.innerHTML = `
|
this.container.innerHTML = `
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-group">
|
<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>
|
<button id="set-reload-btn" class="btn">🔄 Neu laden</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="set-status-msg" style="color: var(--text-muted); font-size: 11px;"></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, '"')}">
|
|
||||||
<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 = `
|
this.container.innerHTML = `
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-group">
|
<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>
|
<button id="workers-refresh-btn" class="btn">🔄 Aktualisieren</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="workers-status-text" style="color: var(--text-muted); font-size: 11px;"></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(
|
execution?.Create(sp) ?? throw new InvalidOperationException(
|
||||||
"DebugTrading braucht den gebundenen Ausführungszugang des Hosts."),
|
"DebugTrading braucht den gebundenen Ausführungszugang des Hosts."),
|
||||||
sp.GetRequiredService<IQuoteSource>(),
|
sp.GetRequiredService<IQuoteSource>(),
|
||||||
|
sp.GetRequiredService<IMarketCalendar>(),
|
||||||
sp.GetRequiredService<SettingsService>()));
|
sp.GetRequiredService<SettingsService>()));
|
||||||
|
|
||||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<DebugTradingWorker>());
|
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<DebugTradingWorker>());
|
||||||
|
|||||||
@@ -41,25 +41,37 @@ public static class DebugTradingPlan
|
|||||||
/// Stückzahl ohne Preis wäre geraten.
|
/// Stückzahl ohne Preis wäre geraten.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="random">Zufallsquelle; in Tests fest vorgegeben.</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(
|
public static IReadOnlyList<DebugAction> Plan(
|
||||||
IReadOnlyList<OpenTestPosition> open,
|
IReadOnlyList<OpenTestPosition> open,
|
||||||
DateTime nowUtc,
|
DateTime nowUtc,
|
||||||
decimal spentToday,
|
decimal spentToday,
|
||||||
DebugTradingSettings settings,
|
DebugTradingSettings settings,
|
||||||
Func<InstrumentRef, decimal?> priceOf,
|
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.
|
// 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,
|
actions.Add(new DebugAction.Close(position,
|
||||||
$"Haltedauer abgelaufen ({position.CloseAtUtc:yyyy-MM-dd HH:mm} UTC)."));
|
$"Haltedauer abgelaufen ({position.CloseAtUtc:yyyy-MM-dd HH:mm} UTC)."));
|
||||||
|
|
||||||
// 2. Eröffnen nur, wenn alle Grenzen es zulassen.
|
// 2. Eröffnen nur, wenn alle Grenzen es zulassen.
|
||||||
if (settings.StopOpening) return actions;
|
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 (stillOpen >= settings.MaxOpenPositions) return actions;
|
||||||
if (spentToday >= (decimal)settings.DailyBudget) 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.
|
// 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
|
// 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.
|
// 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;
|
if (priceOf(instrument) is not { } price || price <= 0m) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public class DebugTradingWorker : WorkerBase
|
|||||||
{
|
{
|
||||||
private readonly IModuleExecution _execution;
|
private readonly IModuleExecution _execution;
|
||||||
private readonly IQuoteSource _quotes;
|
private readonly IQuoteSource _quotes;
|
||||||
|
private readonly IMarketCalendar _calendar;
|
||||||
private readonly SettingsService _settings;
|
private readonly SettingsService _settings;
|
||||||
private readonly IDbContextFactory<DebugTradingDbContext> _own;
|
private readonly IDbContextFactory<DebugTradingDbContext> _own;
|
||||||
private readonly Random _random = new();
|
private readonly Random _random = new();
|
||||||
@@ -44,12 +45,14 @@ public class DebugTradingWorker : WorkerBase
|
|||||||
IDbContextFactory<DebugTradingDbContext> own,
|
IDbContextFactory<DebugTradingDbContext> own,
|
||||||
IModuleExecution execution,
|
IModuleExecution execution,
|
||||||
IQuoteSource quotes,
|
IQuoteSource quotes,
|
||||||
|
IMarketCalendar calendar,
|
||||||
SettingsService settings)
|
SettingsService settings)
|
||||||
: base(logger, coreDb)
|
: base(logger, coreDb)
|
||||||
{
|
{
|
||||||
_own = own;
|
_own = own;
|
||||||
_execution = execution;
|
_execution = execution;
|
||||||
_quotes = quotes;
|
_quotes = quotes;
|
||||||
|
_calendar = calendar;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
Info.Active = settings.Settings.DebugTrading.Enabled;
|
Info.Active = settings.Settings.DebugTrading.Enabled;
|
||||||
}
|
}
|
||||||
@@ -68,11 +71,18 @@ public class DebugTradingWorker : WorkerBase
|
|||||||
var spentToday = await SpentTodayAsync(ct);
|
var spentToday = await SpentTodayAsync(ct);
|
||||||
var quotes = new Dictionary<string, decimal?>(StringComparer.Ordinal);
|
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(
|
var actions = DebugTradingPlan.Plan(
|
||||||
[.. open.Select(ToPlanned)],
|
[.. open.Select(ToPlanned)],
|
||||||
DateTime.UtcNow, spentToday, Config,
|
DateTime.UtcNow, spentToday, Config,
|
||||||
instrument => QuoteOf(instrument, quotes, ct),
|
instrument => QuoteOf(instrument, quotes, ct),
|
||||||
_random);
|
_random,
|
||||||
|
tradable);
|
||||||
|
|
||||||
if (actions.Count == 0)
|
if (actions.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -220,6 +230,57 @@ public class DebugTradingWorker : WorkerBase
|
|||||||
return true;
|
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 ─────────────────────────────────────────────────────────────
|
// ─── Beschaffung ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async Task<IReadOnlyList<DebugPosition>> LoadOpenAsync(CancellationToken ct)
|
private async Task<IReadOnlyList<DebugPosition>> LoadOpenAsync(CancellationToken ct)
|
||||||
|
|||||||
@@ -186,4 +186,85 @@ public class DebugTradingLogicTests
|
|||||||
|
|
||||||
parsed.Should().ContainSingle().Which.Key.Should().Be("SAP:EUR");
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,19 +83,45 @@ public class AuthServiceTests
|
|||||||
sut.IsAuthenticated(ctx).Should().BeTrue();
|
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]
|
[Fact]
|
||||||
public void IsAuthenticated_RecognizesQueryToken()
|
public void IsAuthenticated_AcceptsQueryToken_OnTheLogStream()
|
||||||
{
|
{
|
||||||
var settings = new SettingsService();
|
var sut = SutWithToken("my-secret-key");
|
||||||
settings.Settings.Webserver.AuthToken = "my-secret-key";
|
|
||||||
var sut = new AuthService(settings);
|
|
||||||
|
|
||||||
var ctx = new Microsoft.AspNetCore.Http.DefaultHttpContext();
|
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");
|
ctx.Request.QueryString = new Microsoft.AspNetCore.Http.QueryString("?token=my-secret-key");
|
||||||
|
|
||||||
sut.IsAuthenticated(ctx).Should().BeTrue();
|
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]
|
[Fact]
|
||||||
public void IsAuthenticated_RejectsUnauthenticatedRequest()
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user