feat: Web-UI als zweiter Ausgabeweg neben Avalonia-Shell (#490)
Build & Test / build (ubuntu-latest) (push) Canceled after 0s
Build & Test / build (windows-latest) (push) Canceled after 0s

- Einheitlicher WebserverWorker in IBKRTrader.Hosting auf Kestrel-Basis
- Token-Authentifizierung (AuthService) mit zeitkonstantem Vergleich und HMAC-Session-Tokens
- OrderManager im Core fuer Bestands- und Reservierungsabgleich offener Broker-Orders
- SettingsSchema fuer reflexionsbasiertes Auslesen und sichere Aktualisierung ohne Klartext-Passwoerter
- SSE-Streaming (LogStreamService) fuer Echtzeit-Logs ueber isolierte Channels
- Statische Single-Page-Application in wwwroot (Avalonia Fluent / Polytrader Theme) fuer alle 9 Ansichten
- Unit-Tests fuer OrderManager, AuthService, SettingsSchema, LogStreamService und WebserverWorker (498 Tests gruen)
This commit is contained in:
Richard
2026-09-06 19:55:40 +02:00
parent 7299148dc4
commit f923721784
35 changed files with 4856 additions and 370 deletions
+5
View File
@@ -57,3 +57,8 @@ artifacts/
## Deploymentcenter-Token aus der Selbst-Provisionierung (Werkbank + Laufzeit) ## Deploymentcenter-Token aus der Selbst-Provisionierung (Werkbank + Laufzeit)
.dc-tokens.local.json .dc-tokens.local.json
## Python
__pycache__/
*.pyc
+32 -21
View File
@@ -93,7 +93,7 @@ Strike-Wahl delta-basiert im Band 0,15–0,30. Nur Watchlist, kein Screening.
--- ---
<!-- dc:roadmap stand=2026-09-01T07:48Z items=68 projekt=ibkrtrader --> <!-- dc:roadmap stand=2026-09-06T16:53Z items=79 projekt=ibkrtrader -->
<!-- ERZEUGT - nicht von Hand aendern. Dieser Block wird bei jedem Lauf von <!-- ERZEUGT - nicht von Hand aendern. Dieser Block wird bei jedem Lauf von
`php cli/roadmap.php --write` ueberschrieben. Der Zustand wird im `php cli/roadmap.php --write` ueberschrieben. Der Zustand wird im
@@ -102,21 +102,23 @@ Strike-Wahl delta-basiert im Band 0,15–0,30. Nur Watchlist, kein Screening.
### Als Naechstes ### Als Naechstes
1. **[X] #388 — WorkerBase: Intervall ueber 24,85 Tage toetet den Worker still und reisst beim Herunterfahren den Prozess mit** 1. **[X] #396 — Die Shell reagiert unter Linux nicht auf SIGTERM - kein geordnetes Herunterfahren ausserhalb des Fensters**
2. **[X] #390 — Fehlgeschlagener DB-Dump gilt als erfolgreicher Backup-Lauf** 2. **[X] #475 — Order-Nachweis auf der Test-VM durch die Lizenzsperre blockiert: kein Schluessel hinterlegt**
3. **[X] #389 — BackupWorker sichert eine andere Datenbank als die, mit der die Anwendung arbeitet** 3. **[B] #289 — DB-Passwort der produktiven MariaDB rotieren**
4. **[X] #391 — --check meldet "Pruefdurchlauf OK", ohne die Datenbank je zu erreichen** 4. **[B] #290 — Gitea-Zugangstoken aus der Remote-URL nehmen**
### X — Kritische Bugs ### X — Kritische Bugs
| | Vorhaben | Stand | Ziel | | | Vorhaben | Stand | Ziel |
|---|---|---|---| |---|---|---|---|
| #388 | WorkerBase: Intervall ueber 24,85 Tage toetet den Worker still und reisst beim Herunterfahren den Prozess mit | 📋 Offen | — | | #388 | WorkerBase: Intervall ueber 24,85 Tage toetet den Worker still und reisst beim Herunterfahren den Prozess mit | ✅ Erledigt | `1.0.0+b618efa` |
| #390 | Fehlgeschlagener DB-Dump gilt als erfolgreicher Backup-Lauf | 📋 Offen | — | | #390 | Fehlgeschlagener DB-Dump gilt als erfolgreicher Backup-Lauf | ✅ Erledigt | `1.0.0+b618efa` |
| #389 | BackupWorker sichert eine andere Datenbank als die, mit der die Anwendung arbeitet | 📋 Offen | — | | #389 | BackupWorker sichert eine andere Datenbank als die, mit der die Anwendung arbeitet | ✅ Erledigt | `1.0.0+b618efa` |
| #391 | --check meldet "Pruefdurchlauf OK", ohne die Datenbank je zu erreichen | 📋 Offen | — | | #391 | --check meldet "Pruefdurchlauf OK", ohne die Datenbank je zu erreichen | ✅ Erledigt | `1.0.0+b618efa` |
| #394 | CapitolTrades-Scraper behandelt 429 nicht - beide CT-Worker fallen bei jedem Lauf aus | 📋 Offen | — | | #394 | CapitolTrades-Scraper behandelt 429 nicht - beide CT-Worker fallen bei jedem Lauf aus | ✅ Erledigt | `1.0.0+b618efa` |
| #396 | Die Shell reagiert unter Linux nicht auf SIGTERM - kein geordnetes Herunterfahren ausserhalb des Fensters | 📋 Offen | — | | #396 | Die Shell reagiert unter Linux nicht auf SIGTERM - kein geordnetes Herunterfahren ausserhalb des Fensters | 📋 Offen | — |
| #475 | Order-Nachweis auf der Test-VM durch die Lizenzsperre blockiert: kein Schluessel hinterlegt | 📋 Offen | — |
| #479 | Order lief auf die falsche Notierung: Waehrung ging beim Bau der OrderRequest verloren | ✅ Erledigt | `1.0.0` |
### B — Betrieb und Sicherheit ### B — Betrieb und Sicherheit
@@ -124,34 +126,39 @@ Strike-Wahl delta-basiert im Band 0,15–0,30. Nur Watchlist, kein Screening.
|---|---|---|---| |---|---|---|---|
| #289 | DB-Passwort der produktiven MariaDB rotieren | 📋 Offen | — | | #289 | DB-Passwort der produktiven MariaDB rotieren | 📋 Offen | — |
| #290 | Gitea-Zugangstoken aus der Remote-URL nehmen | 📋 Offen | — | | #290 | Gitea-Zugangstoken aus der Remote-URL nehmen | 📋 Offen | — |
| #291 | EF-Schema auf die Datenbank anwenden | 📋 Offen | — | | #291 | EF-Schema auf die Datenbank anwenden | ✅ Erledigt | `1.0.0` |
### H — Handelspfad (TWS-Broker-Adapter) ### H — Handelspfad (TWS-Broker-Adapter)
| | Vorhaben | Stand | Ziel | | | Vorhaben | Stand | Ziel |
|---|---|---|---| |---|---|---|---|
| #293 | PlaceOrderAsync gegen das Paper-Konto verifizieren | 📋 Offen | — | | #293 | PlaceOrderAsync gegen das Paper-Konto verifizieren | ✅ Erledigt | `1.0.0` |
| #294 | Asynchrone Order- und Fill-Verfolgung | 📋 Offen | — | | #294 | Asynchrone Order- und Fill-Verfolgung | 📋 Offen | — |
| #295 | IBKR-Zugangsdaten mit EncryptedStringConverter ablegen | 📋 Offen | — | | #295 | IBKR-Zugangsdaten mit EncryptedStringConverter ablegen | 📋 Offen | — |
| #296 | IBC/IBController einrichten (Auto-Login, taeglicher Gateway-Neustart) | 📋 Offen | — | | #296 | IBC/IBController einrichten (Auto-Login, taeglicher Gateway-Neustart) | 📋 Offen | — |
| #468 | DebugTrading-Modul: erzeugt selbstständig kleine Paper-Trades als Dauerlast | 📋 Offen | — |
### O — Options-Fundament im Core ### O — Options-Fundament im Core
| | Vorhaben | Stand | Ziel | | | Vorhaben | Stand | Ziel |
|---|---|---|---| |---|---|---|---|
| #298 | InstrumentKind/OptionSpec, IbkrMapping.Option, Positionen mit Multiplikator | 📋 Offen | — | | #298 | InstrumentKind/OptionSpec, IbkrMapping.Option, Positionen mit Multiplikator | ✅ Erledigt | `1.0.0` |
| #299 | Optionskette und Greeks im Core (reqSecDefOptParams, tickOptionComputation) | 📋 Offen | — | | #299 | Optionskette und Greeks im Core (reqSecDefOptParams, tickOptionComputation) | ✅ Erledigt | `1.0.0+0feeeba` |
| #300 | Positionsabgleich bei Zuteilung/Verfall und RiskService um sell-to-open erweitern | 📋 Offen | — | | #300 | Positionsabgleich bei Zuteilung/Verfall und RiskService um sell-to-open erweitern | ✅ Erledigt | `1.0.0+b618efa` |
| #301 | IV-Rank als Core-Baustein | 📋 Offen | — | | #301 | IV-Rank als Core-Baustein | 📋 Offen | — |
### K — Kapital- und Buchmodell ### K — Kapital- und Buchmodell
| | Vorhaben | Stand | Ziel | | | Vorhaben | Stand | Ziel |
|---|---|---|---| |---|---|---|---|
| #302 | Buecher und Eigentumsregeln | 📋 Offen | — | | #302 | Buecher und Eigentumsregeln | ✅ Erledigt | `1.0.0+b618efa` |
| #303 | Ein Pool, Obergrenzen | 📋 Offen | — | | #474 | Eigentumsregeln 3 und 4: Modul kann sich als anderes Buch ausgeben, IBrokerClient ist per DI erreichbar | ✅ Erledigt | `1.0.0+b618efa` |
| #304 | Margin-Sperre in vier Schichten | 📋 Offen | — | | #303 | Ein Pool, Obergrenzen | ✅ Erledigt | `1.0.0` |
| #305 | Reservierungen ueber den Order-Lebenszyklus inkl. Crash-Recovery | 📋 Offen | — | | #480 | Kapitalmodell summiert Betraege ueber Waehrungen hinweg ohne Umrechnung | ✅ Erledigt | `1.0.0+b618efa` |
| #304 | Margin-Sperre in vier Schichten | ✅ Erledigt | `1.0.0` |
| #485 | Positionsverwaltung: Herkunft sichtbar, Zuordnung änderbar, Positionen stufenweise sperrbar | 📋 Offen | — |
| #486 | Instrumentenverwaltung: Handelsverbot je Instrument und harte Bestandsobergrenzen | 📋 Offen | — |
| #305 | Reservierungen ueber den Order-Lebenszyklus inkl. Crash-Recovery | ✅ Erledigt | `1.0.0` |
| #306 | Abgleich (Reconciliation) gegen den Broker | 📋 Offen | — | | #306 | Abgleich (Reconciliation) gegen den Broker | 📋 Offen | — |
| #307 | Benachrichtigungen ueber Outbox und Sink-Abstraktion | 📋 Offen | — | | #307 | Benachrichtigungen ueber Outbox und Sink-Abstraktion | 📋 Offen | — |
| #308 | Startwerte festlegen und in settings.example.json dokumentieren | 📋 Offen | — | | #308 | Startwerte festlegen und in settings.example.json dokumentieren | 📋 Offen | — |
@@ -164,7 +171,7 @@ Strike-Wahl delta-basiert im Band 0,15–0,30. Nur Watchlist, kein Screening.
| | Vorhaben | Stand | Ziel | | | Vorhaben | Stand | Ziel |
|---|---|---|---| |---|---|---|---|
| #309 | OptionsWheel: Modul-Geruest | 📋 Offen | — | | #309 | OptionsWheel: Modul-Geruest | 📋 Offen | — |
| #310 | OptionsWheel: reine Strategie-Logik und Tests | 📋 Offen | — | | #310 | OptionsWheel: reine Strategie-Logik und Tests | ✅ Erledigt | `1.0.0` |
| #311 | OptionsWheel: Verdrahtung und vollautomatischer Paper-Betrieb | 📋 Offen | — | | #311 | OptionsWheel: Verdrahtung und vollautomatischer Paper-Betrieb | 📋 Offen | — |
| #312 | OptionsWheel: Earnings-Sperre ueber den IV-Behelf | 📋 Offen | — | | #312 | OptionsWheel: Earnings-Sperre ueber den IV-Behelf | 📋 Offen | — |
| #314 | OptionsWheel: Live-Freigabe | 📋 Offen | — | | #314 | OptionsWheel: Live-Freigabe | 📋 Offen | — |
@@ -183,6 +190,7 @@ Strike-Wahl delta-basiert im Band 0,15–0,30. Nur Watchlist, kein Screening.
| #292 | CI wieder gruen bekommen (SDK als PackageReference statt Cross-Repo-Pfad) | 📋 Offen | — | | #292 | CI wieder gruen bekommen (SDK als PackageReference statt Cross-Repo-Pfad) | 📋 Offen | — |
| #315 | Tokens fuer ibkrtrader vervollstaendigen | 📋 Offen | — | | #315 | Tokens fuer ibkrtrader vervollstaendigen | 📋 Offen | — |
| #316 | Update-Anwenden verdrahten | 📋 Offen | — | | #316 | Update-Anwenden verdrahten | 📋 Offen | — |
| #476 | Test-VM erreicht die Gitea-Instanz nicht - Auslieferung per git pull nicht moeglich | 📋 Offen | — |
| #317 | Erstes echtes Release fahren | 📋 Offen | — | | #317 | Erstes echtes Release fahren | 📋 Offen | — |
| #318 | Deploymentcenter-Baustein in die Projektdatei aufnehmen | ✅ Erledigt | — | | #318 | Deploymentcenter-Baustein in die Projektdatei aufnehmen | ✅ Erledigt | — |
| #319 | Lizenzfenster fuer die Shell | 📋 Offen | — | | #319 | Lizenzfenster fuer die Shell | 📋 Offen | — |
@@ -224,9 +232,12 @@ Strike-Wahl delta-basiert im Band 0,15–0,30. Nur Watchlist, kein Screening.
| #326 | Diagramme einloesen oder die Avalonia-11-Pinnung aufgeben | 📋 Offen | — | | #326 | Diagramme einloesen oder die Avalonia-11-Pinnung aufgeben | 📋 Offen | — |
| #392 | Der Daemon schreibt jede SQL-Anweisung von EF Core auf stdout | 📋 Offen | — | | #392 | Der Daemon schreibt jede SQL-Anweisung von EF Core auf stdout | 📋 Offen | — |
| #393 | Workers-Ansicht: die Spalte "Aktiv" ist wirkungslos - Worker lassen sich zur Laufzeit nicht abschalten | 📋 Offen | — | | #393 | Workers-Ansicht: die Spalte "Aktiv" ist wirkungslos - Worker lassen sich zur Laufzeit nicht abschalten | 📋 Offen | — |
| #395 | "Letzter Lauf" bleibt bei einem fehlgeschlagenen Worker-Lauf leer | 📋 Offen | — | | #395 | "Letzter Lauf" bleibt bei einem fehlgeschlagenen Worker-Lauf leer | ✅ Erledigt | `1.0.0+b618efa` |
| #403 | Die Umgebung des Fehler-Streams steht fest im Kompilat - jede Release-Fassung meldet "production" | 📋 Offen | — | | #403 | Die Umgebung des Fehler-Streams steht fest im Kompilat - jede Release-Fassung meldet "production" | 📋 Offen | — |
| #409 | --smoke-ui braucht unter Linux doch ein Anzeigegeraet und ist damit nicht CI-tauglich | 📋 Offen | — | | #409 | --smoke-ui braucht unter Linux doch ein Anzeigegeraet und ist damit nicht CI-tauglich | 📋 Offen | — |
| #466 | Meldungen der TWS-API mit Umlauten kommen falsch dekodiert im Log an | 📋 Offen | — |
| #477 | Berechnete Eigenschaft ParsedMode wird in settings.json geschrieben | 📋 Offen | — |
| #490 | Web-UI als zweiter Ausgabeweg neben der Avalonia-Shell – Abschätzung und Schnitt | 📋 Offen | — |
| #333 | Web-UI ueber die interne REST-API | 🧊 Zurueckgestellt — Ausloeser: Fernzugriff auf eine laufende Instanz noetig wird | — | | #333 | Web-UI ueber die interne REST-API | 🧊 Zurueckgestellt — Ausloeser: Fernzugriff auf eine laufende Instanz noetig wird | — |
| #337 | InvariantGlobalization=true fuer ein schlankes Linux-Image | ❌ Verworfen | — | | #337 | InvariantGlobalization=true fuer ein schlankes Linux-Image | ❌ Verworfen | — |
+181
View File
@@ -0,0 +1,181 @@
using System.Globalization;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Persistence.Entities;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading;
using Microsoft.EntityFrameworkCore;
namespace IBKRTrader.Core.Orders;
/// <summary>
/// Eine Order im Verwaltungsüberblick: Broker-Auftrag angereichert um die zugehörige Kapitalbindung.
/// </summary>
public sealed record ManagedOrder(
BrokerOrder BrokerOrder,
long? ReservationId,
string? Book,
bool IsBackedByReservation);
/// <summary>
/// Überblick über das Orderbuch des Brokers und die eigenen Bindungen.
/// </summary>
public sealed record OrderOverview(
IReadOnlyList<ManagedOrder> Orders,
IReadOnlyList<CoreReservation> Reservations,
IReadOnlyList<ManagedOrder> UnbackedOrders,
bool BrokerReachable);
/// <summary>
/// Ergebnis einer Order-Stornierung.
/// </summary>
public readonly record struct CancelOrderResult(bool Success, string Message);
/// <summary>
/// Ergebnis eines Sammelstornos aller offenen Orders.
/// </summary>
public readonly record struct CancelAllResult(int Cancelled, int Total, bool Success, string Message);
/// <summary>
/// Orderverwaltung (#488): Sehen, was beim Broker liegt, Abgleich gegen eigene Bindungen
/// und kontrolliertes Stornieren (Einzel- und Sammelstorno).
///
/// <para>Vorbild <see cref="Positions.PositionManager"/>: Fachlichkeit lebt im Core-Dienst,
/// damit Shell, Daemon-CLI und Web-API dasselbe Verhalten teilen.</para>
/// </summary>
public sealed class OrderManager
{
private const string LogModule = "Orders";
private readonly IBrokerOrderBook _orderBook;
private readonly IDbContextFactory<CoreDbContext> _dbf;
private readonly SettingsService _settings;
private readonly LoggingService _logger;
public OrderManager(
IBrokerOrderBook orderBook,
IDbContextFactory<CoreDbContext> dbf,
SettingsService settings,
LoggingService logger)
{
_orderBook = orderBook;
_dbf = dbf;
_settings = settings;
_logger = logger;
}
/// <summary>
/// Ermittelt alle offenen Orders des Brokers und gleicht sie gegen unsere aktiven Bindungen ab.
/// </summary>
public async Task<OrderOverview> GetOrderOverviewAsync(CancellationToken ct = default)
{
IReadOnlyList<BrokerOrder> brokerOrders;
var reachable = false;
try
{
brokerOrders = await _orderBook.GetOpenOrdersAsync(ct).ConfigureAwait(false);
// Wenn der Adapter eine Antwort liefert (auch leer), aber keine Exception wirft,
// prüfen wir, ob die Broker-Verbindung grundsätzlich aktiv ist.
reachable = brokerOrders.Count > 0 || _settings.Settings.IBKR.UseTwsApi;
}
catch (Exception ex)
{
_logger.Warn(LogModule, $"Orderbuch konnte nicht abgerufen werden: {ex.Message}");
brokerOrders = Array.Empty<BrokerOrder>();
}
await using var db = await _dbf.CreateDbContextAsync(ct).ConfigureAwait(false);
var reservations = await db.Reservations
.Where(r => r.State == ReservationState.Reserved || r.State == ReservationState.Submitted)
.OrderBy(r => r.Id)
.ToListAsync(ct)
.ConfigureAwait(false);
var reservationMap = reservations
.Where(r => !string.IsNullOrWhiteSpace(r.BrokerOrderId))
.ToDictionary(r => r.BrokerOrderId!, StringComparer.Ordinal);
var managedOrders = new List<ManagedOrder>(brokerOrders.Count);
var unbacked = new List<ManagedOrder>();
foreach (var bo in brokerOrders)
{
var key = bo.OrderId.ToString(CultureInfo.InvariantCulture);
var isBacked = reservationMap.TryGetValue(key, out var res);
var item = new ManagedOrder(
BrokerOrder: bo,
ReservationId: res?.Id,
Book: res?.Book,
IsBackedByReservation: isBacked);
managedOrders.Add(item);
if (!isBacked) unbacked.Add(item);
}
return new OrderOverview(
Orders: managedOrders,
Reservations: reservations,
UnbackedOrders: unbacked,
BrokerReachable: reachable);
}
/// <summary>
/// Storniert eine einzelne Order beim Broker.
/// </summary>
public async Task<CancelOrderResult> CancelOrderAsync(int orderId, CancellationToken ct = default)
{
try
{
var ok = await _orderBook.CancelOrderAsync(orderId, ct).ConfigureAwait(false);
var text = ok
? string.Create(CultureInfo.InvariantCulture, $"Order {orderId} zurückgenommen.")
: string.Create(CultureInfo.InvariantCulture, $"Order {orderId} NICHT bestätigt zurückgenommen – sie kann weiterhin liegen.");
if (ok) _logger.Warn(LogModule, text);
else _logger.Error(LogModule, text);
return new CancelOrderResult(ok, text);
}
catch (Exception ex)
{
var msg = string.Create(CultureInfo.InvariantCulture, $"Stornieren von Order {orderId} fehlgeschlagen: {ex.Message}");
_logger.Error(LogModule, msg, ex);
return new CancelOrderResult(false, msg);
}
}
/// <summary>
/// Storniert alle offenen Orders beim Broker – streng beschränkt auf den Paper-Modus.
/// </summary>
public async Task<CancelAllResult> CancelAllOrdersAsync(CancellationToken ct = default)
{
var trading = _settings.Settings.Trading;
if (trading.ParsedMode != TradingMode.Paper ||
!string.Equals(trading.Mode?.Trim(), nameof(TradingMode.Paper), StringComparison.OrdinalIgnoreCase))
{
var msg = $"ABBRUCH: Handelsmodus \"{trading.Mode}\" ist nicht eindeutig Paper. " +
"Ein Sammelstorno gibt es nur auf dem Papierkonto.";
_logger.Warn(LogModule, msg);
return new CancelAllResult(0, 0, false, msg);
}
var orders = await _orderBook.GetOpenOrdersAsync(ct).ConfigureAwait(false);
if (orders.Count == 0)
return new CancelAllResult(0, 0, true, "Keine offenen Orders gemeldet – nichts zurückzunehmen.");
var done = 0;
foreach (var o in orders)
{
if (await _orderBook.CancelOrderAsync(o.OrderId, ct).ConfigureAwait(false))
done++;
}
var text = string.Create(CultureInfo.InvariantCulture,
$"{done} von {orders.Count} zurückgenommen.");
_logger.Warn(LogModule, text);
return new CancelAllResult(done, orders.Count, done == orders.Count, text);
}
}
+17 -4
View File
@@ -151,17 +151,30 @@ public class LoggingSettings
[TypeConverter(typeof(ExpandableObjectConverter))] [TypeConverter(typeof(ExpandableObjectConverter))]
public class WebserverSettings public class WebserverSettings
{ {
[Category("Webserver")]
[DisplayName("Aktiviert")]
[Description("Webserver und REST-API beim Start aktivieren")]
public bool Enabled { get; set; } = false;
[Category("Webserver")] [Category("Webserver")]
[DisplayName("Port")] [DisplayName("Port")]
[Description("Port des internen Webservers (Web-UI)")] [Description("Port des internen Webservers (Web-UI)")]
public int Port { get; set; } = 5001; public int Port { get; set; } = 5001;
[Category("Webserver")] [Category("Webserver")]
[DisplayName("Aktiviert")] [DisplayName("Netzwerkzugriff erlauben")]
[Description("Webserver und REST-API beim Start aktivieren")] [Description("Wenn aktiviert, lauscht der Server auf allen Schnittstellen (0.0.0.0), sonst nur lokal (127.0.0.1)")]
public bool Enabled { get; set; } = false; public bool AllowRemoteAccess { get; set; } = true;
public override string ToString() => Enabled ? $"Aktiv auf Port {Port}" : "Deaktiviert"; [Category("Webserver")]
[DisplayName("Auth-Token")]
[Description("Geheimer Token für den Zugriff auf das Web-UI und die REST-API")]
[PasswordPropertyText(true)]
public string AuthToken { get; set; } = "";
public override string ToString() => Enabled
? $"Aktiv auf Port {Port} ({(AllowRemoteAccess ? "Netzwerk" : "Lokal")})"
: "Deaktiviert";
} }
// ─── Workers ───────────────────────────────────────────────────────────────── // ─── Workers ─────────────────────────────────────────────────────────────────
@@ -0,0 +1,236 @@
using System.ComponentModel;
using System.Globalization;
using System.Reflection;
namespace IBKRTrader.Core.Settings;
/// <summary>
/// Beschreibung eines einzelnen bearbeitbaren Einstellungsfelds für Oberflächen (Desktop & Web).
/// </summary>
public sealed class SettingsFieldInfo
{
public required string Path { get; init; }
public required string DisplayName { get; init; }
public required string Description { get; init; }
public required Type ValueType { get; init; }
public required string TypeName { get; init; }
public required bool IsPassword { get; init; }
public string[]? EnumValues { get; init; }
public required Func<object?> Get { get; init; }
public required Action<object?> Set { get; init; }
}
/// <summary>
/// Ein logischer Abschnitt von Einstellungen (z. B. "Trading", "Webserver", "Datenbank").
/// </summary>
public sealed record SettingsSectionInfo(string Title, string Key, IReadOnlyList<SettingsFieldInfo> Fields);
/// <summary>
/// DTO-Struktur für die Web-API: Serialisierbare Repräsentation eines Felds.
/// Geheimnisse (Passwörter/Tokens) werden niemals lesend herausgegeben.
/// </summary>
public sealed record SettingsFieldDto(
string Path,
string DisplayName,
string Description,
string TypeName,
bool IsPassword,
bool HasValue,
object? Value,
string[]? EnumValues);
/// <summary>
/// DTO-Struktur für die Web-API: Serialisierbare Repräsentation eines Abschnitts.
/// </summary>
public sealed record SettingsSectionDto(
string Title,
string Key,
IReadOnlyList<SettingsFieldDto> Fields);
/// <summary>
/// Erstellt strukturierte Einstellungsmetadaten aus den Attributen der <see cref="AppSettings"/>.
/// Geteilt zwischen Desktop (Avalonia) und Web (Minimal API), damit beide exakt dieselbe
/// Struktur und dieselben Validierungsregeln verwenden.
/// </summary>
public static class SettingsSchema
{
private static bool IsLeaf(Type t) =>
t == typeof(string) || t.IsEnum ||
t == typeof(int) || t == typeof(long) || t == typeof(double) ||
t == typeof(decimal) || t == typeof(bool);
private static string TypeToName(Type t)
{
if (t == typeof(bool)) return "bool";
if (t.IsEnum) return "enum";
if (t == typeof(int) || t == typeof(long)) return "int";
if (t == typeof(double) || t == typeof(decimal)) return "decimal";
return "string";
}
/// <summary>
/// Analysiert das Konfigurationsobjekt per Reflection und erzeugt die Abschnittsliste.
/// </summary>
public static IReadOnlyList<SettingsSectionInfo> Build(object root)
{
var sections = new List<SettingsSectionInfo>();
Walk(root, prefixTitle: null, prefixKey: null, sections);
return sections;
}
/// <summary>
/// Erzeugt ein sicheres DTO für die Web-API. Passwörter und Tokens werden maskiert.
/// </summary>
public static IReadOnlyList<SettingsSectionDto> ToDto(object root)
{
var sections = Build(root);
return sections.Select(s => new SettingsSectionDto(
Title: s.Title,
Key: s.Key,
Fields: s.Fields.Select(f =>
{
var val = f.Get();
var hasVal = val != null && (!(val is string str) || !string.IsNullOrWhiteSpace(str));
return new SettingsFieldDto(
Path: f.Path,
DisplayName: f.DisplayName,
Description: f.Description,
TypeName: f.TypeName,
IsPassword: f.IsPassword,
HasValue: hasVal,
// Wenn IsPassword true ist, wird der echte Wert niemals herausgegeben!
Value: f.IsPassword ? null : val,
EnumValues: f.EnumValues);
}).ToList())).ToList();
}
/// <summary>
/// Wendet einen vom Web-Client übergebenen Wert auf das passende Feld an.
/// </summary>
public static bool TrySetValue(object root, string path, object? rawValue)
{
var sections = Build(root);
var field = sections.SelectMany(s => s.Fields)
.FirstOrDefault(f => string.Equals(f.Path, path, StringComparison.OrdinalIgnoreCase));
if (field == null) return false;
// Wenn ein Passwortfeld leer übergeben wird, bleibt das alte Kennwort unberührt
if (field.IsPassword && (rawValue == null || (rawValue is string s && string.IsNullOrWhiteSpace(s))))
return true;
try
{
if (field.ValueType == typeof(bool))
{
var b = rawValue switch
{
bool boolVal => boolVal,
string strVal => bool.TryParse(strVal, out var parsed) && parsed,
_ => false
};
field.Set(b);
return true;
}
if (field.ValueType.IsEnum)
{
var str = rawValue?.ToString();
if (str != null && Enum.TryParse(field.ValueType, str, true, out var enumVal))
{
field.Set(enumVal);
return true;
}
return false;
}
if (field.ValueType == typeof(int))
{
var num = Convert.ToInt32(rawValue, CultureInfo.InvariantCulture);
field.Set(num);
return true;
}
if (field.ValueType == typeof(decimal))
{
var dec = Convert.ToDecimal(rawValue, CultureInfo.InvariantCulture);
field.Set(dec);
return true;
}
if (field.ValueType == typeof(double))
{
var dbl = Convert.ToDouble(rawValue, CultureInfo.InvariantCulture);
field.Set(dbl);
return true;
}
field.Set(rawValue?.ToString() ?? "");
return true;
}
catch
{
return false;
}
}
private static void Walk(object owner, string? prefixTitle, string? prefixKey, List<SettingsSectionInfo> sections)
{
var fields = new List<SettingsFieldInfo>();
foreach (var prop in owner.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
if (!prop.CanRead || prop.GetIndexParameters().Length > 0) continue;
var title = prop.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName ?? prop.Name;
var key = prefixKey == null ? prop.Name : $"{prefixKey}.{prop.Name}";
if (IsLeaf(prop.PropertyType))
{
if (!prop.CanWrite) continue;
var target = owner;
var isPassword = prop.GetCustomAttribute<PasswordPropertyTextAttribute>()?.Password == true ||
prop.Name.Contains("Password", StringComparison.OrdinalIgnoreCase) ||
prop.Name.Contains("ApiToken", StringComparison.OrdinalIgnoreCase) ||
prop.Name.Contains("AuthToken", StringComparison.OrdinalIgnoreCase);
fields.Add(new SettingsFieldInfo
{
Path = key,
DisplayName = title,
Description = prop.GetCustomAttribute<DescriptionAttribute>()?.Description ?? "",
ValueType = prop.PropertyType,
TypeName = TypeToName(prop.PropertyType),
IsPassword = isPassword,
EnumValues = prop.PropertyType.IsEnum ? Enum.GetNames(prop.PropertyType) : null,
Get = () => prop.GetValue(target),
Set = v => prop.SetValue(target, v)
});
continue;
}
if (prop.PropertyType.IsClass && prop.PropertyType.Namespace?.StartsWith("IBKRTrader", StringComparison.Ordinal) == true)
{
var child = prop.GetValue(owner);
if (child != null)
{
Walk(child,
prefixTitle == null ? title : $"{prefixTitle} · {title}",
key,
sections);
}
}
}
if (fields.Count > 0)
{
sections.Add(new SettingsSectionInfo(
Title: prefixTitle ?? "Allgemein",
Key: prefixKey ?? "Root",
Fields: fields));
}
}
}
@@ -1,160 +0,0 @@
using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore;
using System.Net;
using System.Text;
using System.Text.Json;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Positions;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Core.Workers.BuiltIn;
/// <summary>
/// WebApiService – permanenter REST-API-Service auf Basis von HttpListener.
///
/// <para>Routen: <c>GET /api/status</c>, <c>GET /api/workers</c>, <c>GET /api/positions</c>.</para>
///
/// <para><b>Ausschließlich lesend, und das ist Absicht.</b> Der Listener lauscht auf
/// <c>localhost</c> und kennt keine Anmeldung. Solange das so ist, gehört hierher nichts, was
/// etwas verändert – eine Umbuchung oder eine Sperre über eine offene Schnittstelle wäre ein
/// Eingriff in die Bücher, den niemand belegen kann. Schreibende Endpunkte kommen erst mit
/// Anmeldung und Herkunftsnachweis.</para>
/// </summary>
public class WebApiService : WorkerBase
{
private readonly SettingsService _settings;
private readonly PositionManager _positions;
private WorkerEngine? _engine; // wird nach DI-Aufbau gesetzt (kein readonly)
private HttpListener? _listener;
private static readonly JsonSerializerOptions JsonOpts =
new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
public override string Name => "WebApiService";
public override string Module => "Core";
public override WorkerType Type => WorkerType.Service;
protected override TimeSpan? Interval => null;
public WebApiService(LoggingService logger, IDbContextFactory<CoreDbContext> db,
SettingsService settings, PositionManager positions)
: base(logger, db)
{
_settings = settings;
_positions = positions;
Info.Active = settings.Settings.Webserver.Enabled;
Info.RunEvery = "Service";
}
/// <summary>Setzt die WorkerEngine nach DI-Aufbau (zirkulare Abhängigkeit vermeiden).</summary>
public void SetEngine(WorkerEngine engine) => _engine = engine;
protected override async Task ExecuteAsync(CancellationToken ct)
{
var port = _settings.Settings.Webserver.Port + 1; // API auf Port+1
_listener = new HttpListener();
_listener.Prefixes.Add($"http://localhost:{port}/api/");
try
{
_listener.Start();
Logger.Info(Module, $"WebAPI gestartet auf http://localhost:{port}/api/");
Info.Info = $"http://localhost:{port}/api/";
while (!ct.IsCancellationRequested)
{
var contextTask = _listener.GetContextAsync();
var cancelTask = Task.Delay(Timeout.Infinite, ct);
var completed = await Task.WhenAny(contextTask, cancelTask);
if (completed == cancelTask) break;
var ctx = await contextTask;
_ = RouteAsync(ctx);
}
}
finally
{
_listener.Stop();
Logger.Info(Module, "WebAPI gestoppt.");
}
}
/// <summary>
/// Der Positionsbestand über alle Bücher (#485).
///
/// <para>Die Broker-Abfrage kostet eine Verbindung und mehrere Sekunden und bleibt deshalb
/// abschaltbar: <c>?broker=0</c> antwortet allein aus der eigenen Buchführung. <c>brokerQuantity</c>
/// ist <c>null</c>, wenn der Broker nichts gemeldet hat – das ist ausdrücklich etwas anderes als
/// null Stück, und ein Client darf daraus keine Abweichung ableiten.</para>
/// </summary>
private async Task<object> PositionsAsync(HttpListenerContext ctx)
{
var includeBroker = !string.Equals(
ctx.Request.QueryString["broker"], "0", StringComparison.Ordinal);
var overview = await _positions.GetOverviewAsync(includeBroker).ConfigureAwait(false);
return new
{
brokerReachable = overview.BrokerReachable,
positions = overview.Positions.Select(p => new
{
book = p.Book.Value,
origin = p.Origin,
instrument = p.Instrument.Key,
currency = p.Instrument.Currency,
quantity = p.Quantity,
avgPrice = p.AvgPrice,
boundCapital = p.BoundCapital,
brokerQuantity = p.BrokerQuantity,
diverges = p.Diverges,
positionLock = p.Lock.ToString(),
blocked = p.Rule.Blocked,
maxQuantity = p.Rule.MaxQuantity
}),
unknownToUs = overview.UnknownToUs.Select(p => new
{
symbol = p.Symbol,
secType = p.SecType,
currency = p.Currency,
quantity = p.Quantity,
avgCost = p.AvgCost
})
};
}
private async Task RouteAsync(HttpListenerContext ctx)
{
try
{
var path = ctx.Request.Url?.AbsolutePath.TrimEnd('/').ToLowerInvariant() ?? "";
object? result = path switch
{
"/api/status" => new { status = "ok", time = DateTime.UtcNow },
"/api/workers" => _engine?.WorkerInfos
.Select(w => new { w.WorkerName, w.Module, w.Type,
w.Info, w.Active }) ?? [],
"/api/positions" => await PositionsAsync(ctx).ConfigureAwait(false),
_ => null
};
if (result == null)
{
ctx.Response.StatusCode = 404;
ctx.Response.Close();
return;
}
var json = JsonSerializer.Serialize(result, JsonOpts);
var bytes = Encoding.UTF8.GetBytes(json);
ctx.Response.ContentType = "application/json";
ctx.Response.ContentLength64 = bytes.Length;
ctx.Response.StatusCode = 200;
await ctx.Response.OutputStream.WriteAsync(bytes);
ctx.Response.Close();
}
catch { }
}
}
@@ -1,82 +0,0 @@
using IBKRTrader.Core.Persistence.Ef;
using Microsoft.EntityFrameworkCore;
using System.Net;
using System.Text;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Core.Workers.BuiltIn;
/// <summary>
/// WebserverService – permanenter Service auf Basis von System.Net.HttpListener.
/// Lauscht auf http://localhost:{port}/ und liefert einen Status-JSON.
/// Wird später durch vollständige Web-UI erweitert.
/// </summary>
public class WebserverService : WorkerBase
{
private readonly SettingsService _settings;
private HttpListener? _listener;
public override string Name => "WebserverService";
public override string Module => "Core";
public override WorkerType Type => WorkerType.Service;
protected override TimeSpan? Interval => null; // Service = permanent
public WebserverService(LoggingService logger, IDbContextFactory<CoreDbContext> db, SettingsService settings)
: base(logger, db)
{
_settings = settings;
Info.Active = settings.Settings.Webserver.Enabled;
Info.RunEvery = "Service";
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
var port = _settings.Settings.Webserver.Port;
_listener = new HttpListener();
_listener.Prefixes.Add($"http://localhost:{port}/");
try
{
_listener.Start();
Logger.Info(Module, $"Webserver gestartet auf http://localhost:{port}/");
Info.Info = $"http://localhost:{port}/";
while (!ct.IsCancellationRequested)
{
// GetContextAsync blockiert – abbrechen via ct
var contextTask = _listener.GetContextAsync();
var cancelTask = Task.Delay(Timeout.Infinite, ct);
var completed = await Task.WhenAny(contextTask, cancelTask);
if (completed == cancelTask) break;
var ctx = await contextTask;
_ = HandleRequestAsync(ctx); // fire-and-forget pro Request
}
}
finally
{
_listener.Stop();
Logger.Info(Module, "Webserver gestoppt.");
}
}
private async Task HandleRequestAsync(HttpListenerContext ctx)
{
try
{
const string json = """{"status":"ok","app":"IBKRTrader","version":"1.0.0"}""";
var bytes = Encoding.UTF8.GetBytes(json);
ctx.Response.ContentType = "application/json";
ctx.Response.ContentLength64 = bytes.Length;
ctx.Response.StatusCode = 200;
await ctx.Response.OutputStream.WriteAsync(bytes);
ctx.Response.Close();
}
catch { /* Client hat getrennt */ }
}
}
+28 -94
View File
@@ -1,43 +1,27 @@
using System.Globalization; using System.Globalization;
using IBKRTrader.Core.Persistence.Ef; using IBKRTrader.Core.Orders;
using IBKRTrader.Core.Persistence.Entities;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.Daemon; namespace IBKRTrader.Daemon;
/// <summary> /// <summary>
/// Der Blick ins Orderbuch des Brokers – und der Abgleich mit dem, was wir selbst gebucht haben /// Der Blick ins Orderbuch des Brokers – und der Abgleich mit dem, was wir selbst gebucht haben
/// (#488). /// (#488). Nutzt den Core-Dienst <see cref="OrderManager"/>.
///
/// <para><b>Wozu es das gibt.</b> Am 2026-09-06 stellte sich heraus, dass vier Orders bei
/// geschlossener Börse angenommen und für Montag vorgemerkt wurden, während der Adapter sie als
/// „abgelehnt" meldete und das Kapitalmodell die Bindungen freigab. Aufgefallen ist das nur, weil
/// jemand nachgesehen hat. Damit das nicht vom Zufall abhängt, ist der Gegencheck ein
/// Werkzeug.</para>
///
/// <para><b>Die Aussage, die dieser Schalter nicht macht:</b> Eine leere Liste heißt nicht „keine
/// offenen Orders". Sie heißt auch bei jeder Verbindungsstörung so. Deshalb wird die Verbindung
/// vorher ausdrücklich geprüft und ein Fehlschlag als solcher gemeldet.</para>
/// </summary> /// </summary>
internal static class OrderBookTool internal static class OrderBookTool
{ {
public static async Task<int> ListAsync(IServiceProvider services) public static async Task<int> ListAsync(IServiceProvider services)
{ {
var inv = CultureInfo.InvariantCulture; var inv = CultureInfo.InvariantCulture;
var book = services.GetRequiredService<IBrokerOrderBook>(); var manager = services.GetRequiredService<OrderManager>();
var dbf = services.GetRequiredService<IDbContextFactory<CoreDbContext>>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(90)); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(90));
var overview = await manager.GetOrderOverviewAsync(cts.Token).ConfigureAwait(false);
var orders = await book.GetOpenOrdersAsync(cts.Token).ConfigureAwait(false);
Console.WriteLine(); Console.WriteLine();
Console.WriteLine("=== Offene Orders beim Broker ==="); Console.WriteLine("=== Offene Orders beim Broker ===");
if (orders.Count == 0) if (overview.Orders.Count == 0)
{ {
Console.WriteLine("Keine gemeldet."); Console.WriteLine("Keine gemeldet.");
Console.WriteLine("ACHTUNG: Das heißt nur dann „keine offenen Orders\", wenn oben eine"); Console.WriteLine("ACHTUNG: Das heißt nur dann „keine offenen Orders\", wenn oben eine");
@@ -45,49 +29,29 @@ internal static class OrderBookTool
} }
else else
{ {
foreach (var o in orders) foreach (var o in overview.Orders)
Console.WriteLine($" {o}"); Console.WriteLine($" {o.BrokerOrder}");
} }
// Der eigentliche Punkt: Was sagt unsere Buchführung dazu?
await using var db = await dbf.CreateDbContextAsync(cts.Token);
var bound = await db.Reservations
.Where(r => r.State == ReservationState.Reserved || r.State == ReservationState.Submitted)
.OrderBy(r => r.Id)
.ToListAsync(cts.Token);
Console.WriteLine(); Console.WriteLine();
Console.WriteLine("=== Eigene Bindungen, die noch auf ein Ergebnis warten ==="); Console.WriteLine("=== Eigene Bindungen, die noch auf ein Ergebnis warten ===");
if (bound.Count == 0) if (overview.Reservations.Count == 0)
Console.WriteLine("Keine."); Console.WriteLine("Keine.");
else else
foreach (var r in bound) foreach (var r in overview.Reservations)
Console.WriteLine( Console.WriteLine(
$" #{r.Id} {r.Book} {r.Side} {r.Quantity}x {r.ContractKey} – " + $" #{r.Id} {r.Book} {r.Side} {r.Quantity}x {r.ContractKey} – " +
$"{r.Amount.ToString("N2", inv)} {r.Currency}, Zustand {r.State}, " + $"{r.Amount.ToString("N2", inv)} {r.Currency}, Zustand {r.State}, " +
$"Order {r.BrokerOrderId ?? "(keine ID)"}"); $"Order {r.BrokerOrderId ?? "(keine ID)"}");
// Die beiden Richtungen, in denen es auseinanderlaufen kann. Beide sind Befunde, keine
// Formalien: links Geld, das gebunden ist, ohne dass etwas liegt; rechts eine Order, die
// liegt, ohne dass Geld dafür gebunden wäre.
var knownIds = bound
.Where(r => !string.IsNullOrWhiteSpace(r.BrokerOrderId))
.Select(r => r.BrokerOrderId!)
.ToHashSet(StringComparer.Ordinal);
var unbacked = orders
.Where(o => !knownIds.Contains(o.OrderId.ToString(inv)))
.ToList();
Console.WriteLine(); Console.WriteLine();
Console.WriteLine("=== Befund ==="); Console.WriteLine("=== Befund ===");
if (unbacked.Count > 0) if (overview.UnbackedOrders.Count > 0)
{ {
Console.WriteLine($"{unbacked.Count} Order(s) liegen beim Broker, ohne dass eine Bindung sie kennt:"); Console.WriteLine($"{overview.UnbackedOrders.Count} Order(s) liegen beim Broker, ohne dass eine Bindung sie kennt:");
foreach (var o in unbacked) foreach (var o in overview.UnbackedOrders)
Console.WriteLine($" {o}"); Console.WriteLine($" {o.BrokerOrder}");
Console.WriteLine(); Console.WriteLine();
Console.WriteLine("Das ist der gefährliche Fall: Führt eine davon aus, entsteht eine Position,"); Console.WriteLine("Das ist der gefährliche Fall: Führt eine davon aus, entsteht eine Position,");
Console.WriteLine("für die kein Kapital reserviert war. Zurücknehmen mit"); Console.WriteLine("für die kein Kapital reserviert war. Zurücknehmen mit");
@@ -117,62 +81,32 @@ internal static class OrderBookTool
return 0; return 0;
} }
var book = services.GetRequiredService<IBrokerOrderBook>(); var manager = services.GetRequiredService<OrderManager>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(90)); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(90));
var result = await manager.CancelOrderAsync(orderId, cts.Token).ConfigureAwait(false);
var ok = await book.CancelOrderAsync(orderId, cts.Token).ConfigureAwait(false); Console.WriteLine(result.Message);
return result.Success ? 0 : 1;
Console.WriteLine(ok
? $"Order {orderId} zurückgenommen."
: $"Order {orderId} NICHT bestätigt zurückgenommen – sie kann weiterhin liegen. " +
"Mit --open-orders nachsehen.");
return ok ? 0 : 1;
} }
/// <summary>
/// Nimmt <b>alle</b> offenen Orders zurück – das Aufräumwerkzeug nach einem Testlauf.
///
/// <para><b>Nur im Paper-Modus.</b> Das ist keine Formalie: Ein Schalter, der auf Zuruf jede
/// Order eines Live-Kontos löscht, gehört nicht in ein Kommandozeilenwerkzeug. Wer auf einem
/// Live-Konto aufräumen will, tut es in TWS, wo er sieht, was er wegwirft.</para>
/// </summary>
private static async Task<int> CancelAllAsync(IServiceProvider services, bool confirm) private static async Task<int> CancelAllAsync(IServiceProvider services, bool confirm)
{ {
var trading = services.GetRequiredService<SettingsService>().Settings.Trading;
if (trading.ParsedMode != TradingMode.Paper ||
!string.Equals(trading.Mode?.Trim(), nameof(TradingMode.Paper), StringComparison.OrdinalIgnoreCase))
{
Console.Error.WriteLine($"ABBRUCH: Handelsmodus \"{trading.Mode}\" ist nicht eindeutig Paper. " +
"Ein Sammelstorno gibt es nur auf dem Papierkonto.");
return 1;
}
var book = services.GetRequiredService<IBrokerOrderBook>();
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10));
var orders = await book.GetOpenOrdersAsync(cts.Token).ConfigureAwait(false);
if (orders.Count == 0)
{
Console.WriteLine("Keine offenen Orders gemeldet – nichts zurückzunehmen.");
return 0;
}
Console.WriteLine($"{orders.Count} offene Order(s) beim Broker.");
if (!confirm) if (!confirm)
{ {
Console.WriteLine("TROCKENLAUF – nichts zurückgenommen. Zum Ausführen mit --confirm wiederholen."); Console.WriteLine("TROCKENLAUF – nichts zurückgenommen. Zum Ausführen mit --confirm wiederholen.");
return 0; return 0;
} }
var done = 0; var manager = services.GetRequiredService<OrderManager>();
foreach (var o in orders) using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10));
{ var result = await manager.CancelAllOrdersAsync(cts.Token).ConfigureAwait(false);
if (await book.CancelOrderAsync(o.OrderId, cts.Token).ConfigureAwait(false)) done++;
}
Console.WriteLine($"{done} von {orders.Count} zurückgenommen."); Console.WriteLine(result.Message);
Console.WriteLine("Mit --open-orders gegenprüfen – eine Bestätigung ist keine Feststellung."); if (result.Success)
return done == orders.Count ? 0 : 1; Console.WriteLine("Mit --open-orders gegenprüfen – eine Bestätigung ist keine Feststellung.");
else
Console.Error.WriteLine(result.Message);
return result.Success ? 0 : 1;
} }
} }
+11 -8
View File
@@ -22,6 +22,7 @@ using IBKRTrader.Modules.CongressTrading;
using IBKRTrader.Modules.DebugTrading; using IBKRTrader.Modules.DebugTrading;
using IBKRTrader.Modules.OptionsWheel; using IBKRTrader.Modules.OptionsWheel;
using IBKRTrader.Modules.Supervisor; using IBKRTrader.Modules.Supervisor;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@@ -119,10 +120,10 @@ public static class AppHostBuilder
// 4b. Kapitalmodell: Anteile pruefen und Kapitalbindungen wiederherstellen. // 4b. Kapitalmodell: Anteile pruefen und Kapitalbindungen wiederherstellen.
CheckCapitalModel(services, logger); CheckCapitalModel(services, logger);
// 5. Zirkuläre Abhängigkeit auflösen: WebApiService und der Watchdog-Heartbeat brauchen die // 5. Zirkuläre Abhängigkeit auflösen: WebserverWorker und der Watchdog-Heartbeat brauchen die
// Engine (vor dem Start), sind aber selbst Teil von deren IEnumerable<IWorker>. // Engine (vor dem Start), sind aber selbst Teil von deren IEnumerable<IWorker>.
var workerEngine = services.GetRequiredService<WorkerEngine>(); var workerEngine = services.GetRequiredService<WorkerEngine>();
services.GetRequiredService<WebApiService>().SetEngine(workerEngine); services.GetRequiredService<Web.WebserverWorker>().SetEngine(workerEngine);
services.GetRequiredService<DcHeartbeatWorker>().SetWorkerEngine(workerEngine); services.GetRequiredService<DcHeartbeatWorker>().SetWorkerEngine(workerEngine);
// 6. Deploymentcenter-Fehlermeldung verdrahten. Ohne globale Handler gab es bisher keine // 6. Deploymentcenter-Fehlermeldung verdrahten. Ohne globale Handler gab es bisher keine
@@ -267,6 +268,7 @@ public static class AppHostBuilder
services.AddSingleton<ReservationService>(); services.AddSingleton<ReservationService>();
services.AddSingleton<TradingRuleService>(); services.AddSingleton<TradingRuleService>();
services.AddSingleton<Core.Positions.PositionManager>(); services.AddSingleton<Core.Positions.PositionManager>();
services.AddSingleton<Core.Orders.OrderManager>();
services.AddSingleton<CapitalService>(); services.AddSingleton<CapitalService>();
services.AddSingleton<ICapitalService>(sp => sp.GetRequiredService<CapitalService>()); services.AddSingleton<ICapitalService>(sp => sp.GetRequiredService<CapitalService>());
@@ -298,8 +300,11 @@ public static class AppHostBuilder
services.AddSingleton<ReconciliationService>(); services.AddSingleton<ReconciliationService>();
services.AddSingleton<ReservationWatchdogWorker>(); services.AddSingleton<ReservationWatchdogWorker>();
services.AddSingleton<PositionReconcileWorker>(); services.AddSingleton<PositionReconcileWorker>();
services.AddSingleton<WebserverService>(); services.AddSingleton<Web.WebserverWorker>(sp => new Web.WebserverWorker(
services.AddSingleton<WebApiService>(); sp.GetRequiredService<LoggingService>(),
sp.GetRequiredService<IDbContextFactory<CoreDbContext>>(),
sp.GetRequiredService<SettingsService>(),
sp));
services.AddSingleton<IBKRInstrumentSyncWorker>(); services.AddSingleton<IBKRInstrumentSyncWorker>();
services.AddSingleton<IBKRPriceHistoryWorker>(); services.AddSingleton<IBKRPriceHistoryWorker>();
services.AddSingleton<DcHeartbeatWorker>(); services.AddSingleton<DcHeartbeatWorker>();
@@ -308,8 +313,7 @@ public static class AppHostBuilder
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<BackupWorker>()); services.AddSingleton<IWorker>(sp => sp.GetRequiredService<BackupWorker>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<ReservationWatchdogWorker>()); services.AddSingleton<IWorker>(sp => sp.GetRequiredService<ReservationWatchdogWorker>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<PositionReconcileWorker>()); services.AddSingleton<IWorker>(sp => sp.GetRequiredService<PositionReconcileWorker>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebserverService>()); services.AddSingleton<IWorker>(sp => sp.GetRequiredService<Web.WebserverWorker>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebApiService>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>()); services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>()); services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>());
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<DcHeartbeatWorker>()); services.AddSingleton<IWorker>(sp => sp.GetRequiredService<DcHeartbeatWorker>());
@@ -318,8 +322,7 @@ public static class AppHostBuilder
services.AddHostedService(sp => sp.GetRequiredService<BackupWorker>()); services.AddHostedService(sp => sp.GetRequiredService<BackupWorker>());
services.AddHostedService(sp => sp.GetRequiredService<ReservationWatchdogWorker>()); services.AddHostedService(sp => sp.GetRequiredService<ReservationWatchdogWorker>());
services.AddHostedService(sp => sp.GetRequiredService<PositionReconcileWorker>()); services.AddHostedService(sp => sp.GetRequiredService<PositionReconcileWorker>());
services.AddHostedService(sp => sp.GetRequiredService<WebserverService>()); services.AddHostedService(sp => sp.GetRequiredService<Web.WebserverWorker>());
services.AddHostedService(sp => sp.GetRequiredService<WebApiService>());
services.AddHostedService(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>()); services.AddHostedService(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
services.AddHostedService(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>()); services.AddHostedService(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>());
services.AddHostedService(sp => sp.GetRequiredService<DcHeartbeatWorker>()); services.AddHostedService(sp => sp.GetRequiredService<DcHeartbeatWorker>());
@@ -11,12 +11,19 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.4" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<!-- systemd-Integration: sd_notify (Type=notify) und journald-taugliches Logformat. <!-- systemd-Integration: sd_notify (Type=notify) und journald-taugliches Logformat.
Ausserhalb von systemd ein No-Op, stoert die Windows-Shell also nicht. --> Ausserhalb von systemd ein No-Op, stoert die Windows-Shell also nicht. -->
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.4" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Content Include="wwwroot\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\IBKRTrader.Core\IBKRTrader.Core.csproj" /> <ProjectReference Include="..\IBKRTrader.Core\IBKRTrader.Core.csproj" />
<ProjectReference Include="..\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" /> <ProjectReference Include="..\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" />
+179
View File
@@ -0,0 +1,179 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using IBKRTrader.Core.Settings;
using Microsoft.AspNetCore.Http;
namespace IBKRTrader.Hosting.Web;
/// <summary>
/// Authentifizierungsdienst für das Web-UI und die REST-API.
/// Validiert den konfigurierten Auth-Token und erzeugt signierte Sitzungs-Tokens (HMAC-SHA256).
/// </summary>
public sealed class AuthService
{
private const string CookieName = "ibkr_session";
private readonly SettingsService _settings;
private readonly byte[] _signingKey;
public AuthService(SettingsService settings)
{
_settings = settings;
// Signaturschlüssel aus MasterKey oder zufälligem Sitzungsschlüssel
var masterKey = Environment.GetEnvironmentVariable("IBKRTRADER_MASTER_KEY");
if (!string.IsNullOrWhiteSpace(masterKey))
{
_signingKey = SHA256.HashData(Encoding.UTF8.GetBytes(masterKey + "_web_auth"));
}
else
{
_signingKey = new byte[32];
RandomNumberGenerator.Fill(_signingKey);
}
}
/// <summary>Der wirksame Auth-Token aus settings.json oder Umgebungsvariable.</summary>
public string ConfiguredToken
{
get
{
var env = Environment.GetEnvironmentVariable("IBKRTRADER_WEB_TOKEN");
if (!string.IsNullOrWhiteSpace(env)) return env.Trim();
return _settings.Settings.Webserver.AuthToken.Trim();
}
}
/// <summary>Ist ein Auth-Token eingerichtet?</summary>
public bool IsConfigured => !string.IsNullOrWhiteSpace(ConfiguredToken);
/// <summary>
/// Prüft einen eingegebenen Token gegen den konfigurierten Wert (zeitkonstanter Vergleich).
/// </summary>
public bool ValidateToken(string? candidate)
{
if (string.IsNullOrWhiteSpace(candidate) || !IsConfigured) return false;
var configuredBytes = Encoding.UTF8.GetBytes(ConfiguredToken);
var candidateBytes = Encoding.UTF8.GetBytes(candidate.Trim());
if (configuredBytes.Length != candidateBytes.Length) return false;
return CryptographicOperations.FixedTimeEquals(configuredBytes, candidateBytes);
}
/// <summary>
/// Erzeugt ein signiertes Session-Token mit Ablaufzeit (standardmäßig 7 Tage).
/// </summary>
public string CreateSessionToken(TimeSpan? lifetime = null)
{
var expiresAt = DateTimeOffset.UtcNow.Add(lifetime ?? TimeSpan.FromDays(7)).ToUnixTimeSeconds();
var payload = JsonSerializer.Serialize(new { exp = expiresAt, rnd = Guid.NewGuid().ToString("N") });
var payloadBytes = Encoding.UTF8.GetBytes(payload);
var payloadB64 = Convert.ToBase64String(payloadBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
var sigBytes = HMACSHA256.HashData(_signingKey, Encoding.UTF8.GetBytes(payloadB64));
var sigB64 = Convert.ToBase64String(sigBytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
return $"{payloadB64}.{sigB64}";
}
/// <summary>
/// Prüft, ob ein Session-Token gültig und noch nicht abgelaufen ist.
/// </summary>
public bool ValidateSessionToken(string? sessionToken)
{
if (string.IsNullOrWhiteSpace(sessionToken)) return false;
var parts = sessionToken.Split('.');
if (parts.Length != 2) return false;
var payloadB64 = parts[0];
var sigB64 = parts[1];
var expectedSig = HMACSHA256.HashData(_signingKey, Encoding.UTF8.GetBytes(payloadB64));
var expectedB64 = Convert.ToBase64String(expectedSig).TrimEnd('=').Replace('+', '-').Replace('/', '_');
var expectedSigBytes = Encoding.UTF8.GetBytes(expectedB64);
var actualSigBytes = Encoding.UTF8.GetBytes(sigB64);
if (expectedSigBytes.Length != actualSigBytes.Length ||
!CryptographicOperations.FixedTimeEquals(expectedSigBytes, actualSigBytes))
{
return false;
}
try
{
var padded = payloadB64.Replace('-', '+').Replace('_', '/');
switch (padded.Length % 4)
{
case 2: padded += "=="; break;
case 3: padded += "="; break;
}
var json = Encoding.UTF8.GetString(Convert.FromBase64String(padded));
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("exp", out var expProp) && expProp.TryGetInt64(out var exp))
{
return DateTimeOffset.UtcNow.ToUnixTimeSeconds() < exp;
}
return false;
}
catch
{
return false;
}
}
/// <summary>
/// Prüft, ob die HTTP-Anfrage authentifiziert ist (Header, Cookie oder Direkt-Token).
/// </summary>
public bool IsAuthenticated(HttpContext context)
{
if (!IsConfigured) return false;
// 1. Authorization: Bearer <sessionToken oder authToken>
var authHeader = context.Request.Headers.Authorization.ToString();
if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
var token = authHeader["Bearer ".Length..].Trim();
if (ValidateSessionToken(token) || ValidateToken(token)) return true;
}
// 2. Cookie: ibkr_session=<sessionToken>
if (context.Request.Cookies.TryGetValue(CookieName, out var cookieToken))
{
if (ValidateSessionToken(cookieToken) || ValidateToken(cookieToken)) return true;
}
// 3. Query-Parameter ?token= (wichtig für SSE EventSource)
if (context.Request.Query.TryGetValue("token", out var queryToken))
{
if (ValidateSessionToken(queryToken) || ValidateToken(queryToken)) return true;
}
return false;
}
/// <summary>Setzt das Session-Cookie in der Response.</summary>
public void SetAuthCookie(HttpContext context, string sessionToken)
{
context.Response.Cookies.Append(CookieName, sessionToken, new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Lax,
Secure = context.Request.IsHttps,
Expires = DateTimeOffset.UtcNow.AddDays(7),
Path = "/"
});
}
/// <summary>Löscht das Session-Cookie in der Response.</summary>
public void ClearAuthCookie(HttpContext context)
{
context.Response.Cookies.Delete(CookieName, new CookieOptions
{
Path = "/"
});
}
}
@@ -0,0 +1,293 @@
using System.Globalization;
using IBKRTrader.Core.Capital;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Orders;
using IBKRTrader.Core.Positions;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
namespace IBKRTrader.Hosting.Web.Endpoints;
/// <summary>
/// Registriert sämtliche Core-Endpunkte der Web-API.
/// </summary>
public static class CoreEndpoints
{
public static void Map(RouteGroupBuilder api)
{
// ─── Status & Auth ────────────────────────────────────────────────────────
api.MapGet("/status", () => new
{
status = "ok",
app = "IBKRTrader",
version = BuildInfo.Summary,
time = DateTime.UtcNow
});
api.MapGet("/auth/status", (HttpContext ctx, AuthService auth) => new
{
authenticated = auth.IsAuthenticated(ctx),
isConfigured = auth.IsConfigured
});
api.MapPost("/auth/login", (LoginRequest req, HttpContext ctx, AuthService auth) =>
{
if (!auth.ValidateToken(req.Token))
return Results.Json(new { ok = false, error = "Ungültiger Auth-Token." }, statusCode: 401);
var sessionToken = auth.CreateSessionToken();
auth.SetAuthCookie(ctx, sessionToken);
return Results.Ok(new { ok = true, token = sessionToken });
});
api.MapPost("/auth/logout", (HttpContext ctx, AuthService auth) =>
{
auth.ClearAuthCookie(ctx);
return Results.Ok(new { ok = true });
});
// ─── Dashboard ────────────────────────────────────────────────────────────
api.MapGet("/dashboard", async (
DashboardService dashboard,
SettingsService settings,
WorkerEngine engine,
IEnumerable<IModule> modules,
IConfiguration config) =>
{
var snap = await dashboard.GetSnapshotAsync().ConfigureAwait(false);
var trading = settings.Settings.Trading;
return new
{
trading = new
{
mode = trading.Mode,
tradingEnabled = trading.TradingEnabled
},
kpis = new
{
openPositions = snap.OpenPositions,
totalExposure = snap.TotalExposure,
totalTrades = snap.TotalTrades,
workerCount = engine.WorkerInfos.Count
},
modules = modules.Select(m => new
{
name = m.Name,
dbPrefix = m.DbPrefix,
blocker = m.GetActivationBlocker(config) ?? "aktivierbar"
})
};
});
// ─── Workers ──────────────────────────────────────────────────────────────
api.MapGet("/workers", (WorkerEngine engine) =>
{
return engine.WorkerInfos.Select(w => new
{
active = w.Active,
type = w.Type,
module = w.Module,
workerName = w.WorkerName,
lastRuntime = w.LastRuntime?.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture),
nextRuntime = w.NextRuntime?.ToString("dd.MM.yyyy HH:mm:ss", CultureInfo.InvariantCulture),
runEvery = w.RunEvery,
info = w.Info,
status = w.Status.ToString()
});
});
api.MapPost("/workers/{name}/trigger", async (string name, WorkerEngine engine) =>
{
try
{
await engine.TriggerWorkerAsync(name).ConfigureAwait(false);
return Results.Ok(new { ok = true, message = $"{name} ausgelöst." });
}
catch (Exception ex)
{
return Results.BadRequest(new { ok = false, error = ex.Message });
}
});
// ─── Positionen ───────────────────────────────────────────────────────────
api.MapGet("/positions", async (HttpContext ctx, PositionManager positions) =>
{
var includeBroker = !string.Equals(ctx.Request.Query["broker"], "0", StringComparison.Ordinal);
var overview = await positions.GetOverviewAsync(includeBroker).ConfigureAwait(false);
return new
{
brokerReachable = overview.BrokerReachable,
positions = overview.Positions.Select(p => new
{
book = p.Book.Value,
origin = p.Origin,
instrument = p.Instrument.Key,
currency = p.Instrument.Currency,
quantity = p.Quantity,
avgPrice = p.AvgPrice,
boundCapital = p.BoundCapital,
brokerQuantity = p.BrokerQuantity,
diverges = p.Diverges,
isRestricted = p.IsRestricted,
positionLock = p.Lock.ToString(),
blocked = p.Rule.Blocked,
maxQuantity = p.Rule.MaxQuantity
}),
unknownToUs = overview.UnknownToUs.Select(p => new
{
symbol = p.Symbol,
secType = p.SecType,
currency = p.Currency,
quantity = p.Quantity,
avgCost = p.AvgCost
})
};
});
api.MapPost("/positions/reassign", async (ReassignRequest req, PositionManager positions) =>
{
var instrument = InstrumentRef.ForStock(req.Instrument);
var fromBook = BookId.Parse(req.FromBook);
var toBook = BookId.Parse(req.ToBook);
var result = await positions.ReassignAsync(instrument, fromBook, toBook, req.Reason).ConfigureAwait(false);
return Results.Ok(new { success = result.Success, message = result.Message });
});
api.MapPost("/positions/lock", async (SetLockRequest req, PositionManager positions) =>
{
var instrument = InstrumentRef.ForStock(req.Instrument);
var book = BookId.Parse(req.Book);
if (!Enum.TryParse<PositionLock>(req.Level, true, out var level))
return Results.BadRequest(new { success = false, message = "Ungültige Sperrstufe." });
await positions.SetLockAsync(book, instrument, level, req.Note).ConfigureAwait(false);
return Results.Ok(new { success = true, message = $"Sperre für {req.Instrument} gesetzt." });
});
api.MapPost("/positions/rule", async (SetRuleRequest req, PositionManager positions) =>
{
var rule = new InstrumentRule(req.Blocked, req.MaxQuantity);
await positions.SetRuleAsync(req.ContractKeyOrSymbol, rule, req.Note).ConfigureAwait(false);
return Results.Ok(new { success = true, message = $"Regel für {req.ContractKeyOrSymbol} gespeichert." });
});
// ─── Orders ───────────────────────────────────────────────────────────────
api.MapGet("/orders", async (OrderManager orderManager) =>
{
var overview = await orderManager.GetOrderOverviewAsync().ConfigureAwait(false);
return new
{
brokerReachable = overview.BrokerReachable,
orders = overview.Orders.Select(o => new
{
orderId = o.BrokerOrder.OrderId,
permId = o.BrokerOrder.PermId,
symbol = o.BrokerOrder.Instrument?.Key ?? "?",
side = o.BrokerOrder.Side.ToString(),
quantity = o.BrokerOrder.Quantity,
filled = o.BrokerOrder.Filled,
remaining = o.BrokerOrder.Remaining,
orderType = o.BrokerOrder.OrderType,
limitPrice = o.BrokerOrder.LimitPrice,
status = o.BrokerOrder.Status,
account = o.BrokerOrder.Account,
reservationId = o.ReservationId,
book = o.Book,
isBackedByReservation = o.IsBackedByReservation
}),
reservations = overview.Reservations.Select(r => new
{
id = r.Id,
book = r.Book,
side = r.Side.ToString(),
quantity = r.Quantity,
contractKey = r.ContractKey,
amount = r.Amount,
currency = r.Currency,
state = r.State.ToString(),
brokerOrderId = r.BrokerOrderId
}),
unbackedOrders = overview.UnbackedOrders.Select(o => new
{
orderId = o.BrokerOrder.OrderId,
symbol = o.BrokerOrder.Instrument?.Key ?? "?",
side = o.BrokerOrder.Side.ToString(),
quantity= o.BrokerOrder.Quantity
})
};
});
api.MapPost("/orders/{id:int}/cancel", async (int id, OrderManager orderManager) =>
{
var result = await orderManager.CancelOrderAsync(id).ConfigureAwait(false);
return Results.Ok(new { success = result.Success, message = result.Message });
});
api.MapPost("/orders/cancel-all", async (OrderManager orderManager) =>
{
var result = await orderManager.CancelAllOrdersAsync().ConfigureAwait(false);
return Results.Ok(new
{
cancelled = result.Cancelled,
total = result.Total,
success = result.Success,
message = result.Message
});
});
// ─── Logs ─────────────────────────────────────────────────────────────────
api.MapGet("/logs/recent", (LogStreamService logStream) =>
{
return logStream.GetRecent();
});
api.MapGet("/logs/stream", async (HttpContext ctx, LogStreamService logStream) =>
{
await logStream.HandleStreamAsync(ctx).ConfigureAwait(false);
});
// ─── Settings ─────────────────────────────────────────────────────────────
api.MapGet("/settings", (SettingsService settings) =>
{
return SettingsSchema.ToDto(settings.Settings);
});
api.MapPost("/settings", (UpdateSettingsRequest req, SettingsService settings) =>
{
if (req.Updates == null || req.Updates.Count == 0)
return Results.BadRequest(new { success = false, message = "Keine Werte übergeben." });
var changed = 0;
foreach (var (path, val) in req.Updates)
{
if (SettingsSchema.TrySetValue(settings.Settings, path, val))
changed++;
}
try
{
settings.Save();
return Results.Ok(new { success = true, message = $"{changed} Einstellung(en) gespeichert." });
}
catch (Exception ex)
{
return Results.BadRequest(new { success = false, message = $"Fehler beim Speichern: {ex.Message}" });
}
});
}
}
public sealed record LoginRequest(string Token);
public sealed record ReassignRequest(string Instrument, string FromBook, string ToBook, string Reason);
public sealed record SetLockRequest(string Instrument, string Book, string Level, string? Note);
public sealed record SetRuleRequest(string ContractKeyOrSymbol, bool Blocked, int? MaxQuantity, string? Note);
public sealed record UpdateSettingsRequest(Dictionary<string, object?> Updates);
@@ -0,0 +1,329 @@
using System.Globalization;
using System.Text.Json;
using IBKRTrader.Core.Analytics;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Time;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers;
using IBKRTrader.Modules.Accounting.Logic;
using IBKRTrader.Modules.Accounting.Persistence;
using IBKRTrader.Modules.Accounting.Services;
using IBKRTrader.Modules.CongressTrading;
using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.Modules.Supervisor.Agent;
using IBKRTrader.Modules.Supervisor.Persistence;
using IBKRTrader.Modules.Supervisor.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.Hosting.Web.Endpoints;
/// <summary>
/// Registriert die Modul-Endpunkte der Web-API.
///
/// <para><b>Architektur-Regel:</b> Der Core darf die Module nicht kennen. IBKRTrader.Hosting kennt
/// sie und registriert deren Routen zentral, analog zu ModuleViews.cs in der Shell.</para>
/// </summary>
public static class ModuleEndpoints
{
public static void Map(RouteGroupBuilder api, IServiceProvider sp)
{
var modules = sp.GetServices<IModule>().ToList();
if (modules.Any(m => string.Equals(m.Name, "CongressTrading", StringComparison.OrdinalIgnoreCase)))
MapCongressTrading(api);
if (modules.Any(m => string.Equals(m.Name, "Accounting", StringComparison.OrdinalIgnoreCase)))
MapAccounting(api);
if (modules.Any(m => string.Equals(m.Name, "Supervisor", StringComparison.OrdinalIgnoreCase)))
MapSupervisor(api);
}
// ─── CongressTrading ──────────────────────────────────────────────────────────
private static void MapCongressTrading(RouteGroupBuilder api)
{
var group = api.MapGroup("/modules/congresstrading");
group.MapGet("/summary", async (CongressRepository repo, IPortfolioService portfolio) =>
{
var trades = await repo.GetTradeCountAsync().ConfigureAwait(false);
var members = await repo.GetMemberCountAsync().ConfigureAwait(false);
var positions = await portfolio.GetPositionsAsync(CongressTradingModule.LogTag).ConfigureAwait(false);
return new
{
trades,
members,
positions = positions.Select(p => new
{
symbol = p.Symbol,
quantity = p.Quantity,
avgPrice = p.AvgPrice,
notional = p.Notional
})
};
});
group.MapPost("/scrape", async (WorkerEngine engine) =>
{
try
{
await engine.TriggerWorkerAsync("CT-ScrapeWorker").ConfigureAwait(false);
return Results.Ok(new { success = true, message = "Scrape-Worker ausgelöst." });
}
catch (Exception ex)
{
return Results.BadRequest(new { success = false, message = ex.Message });
}
});
}
// ─── Accounting ───────────────────────────────────────────────────────────────
private static void MapAccounting(RouteGroupBuilder api)
{
var group = api.MapGroup("/modules/accounting");
group.MapGet("/accounts", (ILedgerRepository ledger) =>
{
return ledger.DistinctAccounts();
});
group.MapGet("/overview", (
AccountingReportService report,
string? account,
string? currency,
DateTime? from,
DateTime? to) =>
{
var fromDate = from ?? DateTime.Today.AddMonths(-1);
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
var curr = string.IsNullOrWhiteSpace(currency) ? "USD" : currency;
var acc = string.IsNullOrWhiteSpace(account) || account == "(alle)" ? null : account;
var stmt = report.BuildStatement(acc, fromDate, toDate);
var view = report.GetCurrencyView(curr, toDate);
var monthly = report.BuildMonthly(acc, fromDate, toDate);
decimal M(decimal v) => Math.Round(v * view.Factor, 2);
return new
{
currencyCode = view.Code,
currencyNote = view.Note,
statement = new
{
netTradingResult = M(stmt.NetTradingResult),
tradeVolume = M(stmt.TradeVolume),
dividends = M(stmt.Dividends),
fees = M(stmt.Fees),
closingBalance = M(stmt.ClosingBalance),
tradeCount = stmt.TradeCount,
entryCount = stmt.EntryCount
},
monthly = monthly.Select(m => new
{
month = m.From.ToString("yyyy-MM", CultureInfo.InvariantCulture),
openingBalance = M(m.OpeningBalance),
deposits = M(m.Deposits),
withdrawals = M(m.Withdrawals),
tradeVolume = M(m.TradeVolume),
fees = M(m.Fees),
netTradingResult = M(m.NetTradingResult),
closingBalance = M(m.ClosingBalance)
})
};
});
group.MapGet("/ledger", (
ILedgerRepository ledger,
string? account,
DateTime? from,
DateTime? to,
int? limit) =>
{
var fromDate = from ?? DateTime.Today.AddMonths(-1);
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
var acc = string.IsNullOrWhiteSpace(account) || account == "(alle)" ? null : account;
return ledger.Query(acc, fromDate, toDate, limit ?? 2000).Select(e => new
{
timestamp = e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
accountId = e.AccountId,
eventType = e.EventType.ToString(),
side = e.Side,
symbol = e.Symbol,
currency = e.Currency,
quantity = e.Quantity,
priceNative = e.PriceNative,
grossBase = e.GrossBase,
feeBase = e.FeeBase,
netBase = e.NetBase,
transactionId = e.TransactionId
});
});
group.MapGet("/runs", (IIngestRunRepository runs, string? account, int? limit) =>
{
var acc = string.IsNullOrWhiteSpace(account) || account == "(alle)" ? null : account;
return runs.GetRecent(acc, limit ?? 100).Select(r => new
{
accountId = r.AccountId,
startedAt = r.StartedAt.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
finishedAt = r.FinishedAt?.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture) ?? "–",
backfill = r.Backfill,
newEntries = r.NewEntries,
duplicateEntries = r.DuplicateEntries,
success = r.Success,
balanceDeltaBase = r.BalanceDeltaBase,
message = r.Message
});
});
group.MapPost("/ingest", async (AccountingIngestService ingest, bool backfill = false) =>
{
try
{
await ingest.IngestAllAsync(backfill, CancellationToken.None).ConfigureAwait(false);
return Results.Ok(new { success = true, message = "Abruf erfolgreich abgeschlossen." });
}
catch (Exception ex)
{
return Results.BadRequest(new { success = false, message = ex.Message });
}
});
group.MapGet("/export/ledger.csv", (ILedgerRepository ledger, string? account, DateTime? from, DateTime? to) =>
{
var fromDate = from ?? DateTime.Today.AddMonths(-1);
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
var acc = string.IsNullOrWhiteSpace(account) || account == "(alle)" ? null : account;
var entries = ledger.Query(acc, fromDate, toDate, 100_000);
var csv = CsvExporter.Ledger(entries);
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) =>
{
var fromDate = from ?? DateTime.Today.AddMonths(-1);
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
var acc = string.IsNullOrWhiteSpace(account) || account == "(alle)" ? null : account;
var stmt = report.BuildStatement(acc, fromDate, toDate);
var csv = CsvExporter.Statement(stmt);
return Results.File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", "abrechnung.csv");
});
group.MapGet("/export/statement.pdf", (
AccountingReportService report,
ILedgerRepository ledger,
string? account,
string? currency,
DateTime? from,
DateTime? to) =>
{
var fromDate = from ?? DateTime.Today.AddMonths(-1);
var toDate = (to ?? DateTime.Today).AddDays(1).AddTicks(-1);
var acc = string.IsNullOrWhiteSpace(account) || account == "(alle)" ? null : account;
var curr = string.IsNullOrWhiteSpace(currency) ? "USD" : currency;
var stmt = report.BuildStatement(acc, fromDate, toDate);
var monthly = report.BuildMonthly(acc, fromDate, toDate);
var entries = ledger.Query(acc, fromDate, toDate, 100_000).OrderBy(e => e.Timestamp).ToList();
var view = report.GetCurrencyView(curr, toDate);
var pdf = PdfExporter.Render(stmt, monthly, entries, view.Code, view.Factor, view.Note);
return Results.File(pdf, "application/pdf", "abrechnung.pdf");
});
}
// ─── Supervisor ───────────────────────────────────────────────────────────────
private static void MapSupervisor(RouteGroupBuilder api)
{
var group = api.MapGroup("/modules/supervisor");
group.MapGet("/profiles", () => SupervisorProfiles.All.Select(p => p.Name));
group.MapGet("/signals", (DossierService dossiers, int? limit) =>
{
return dossiers.RecentSignals(limit ?? 200);
});
group.MapGet("/dossier/{signalId}", (DossierService dossiers, string signalId) =>
{
var dossier = dossiers.BuildForSignal(signalId);
var markdown = DossierBuilder.ToMarkdown(dossier);
return new { signalId, markdown };
});
group.MapGet("/reports", (ISupervisorReportRepository reports, int? limit) =>
{
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
{
var result = await agent.AskAsync(req.Question, profile: profile).ConfigureAwait(false);
reports.Insert(new SupervisorReport
{
Profile = profile.Name,
Model = SupervisorAgent.DefaultModel,
Question = req.Question,
Answer = result.Answer,
ToolCallsJson = JsonSerializer.Serialize(result.ToolInvocations.Select(i => new { i.Tool, i.Arguments })),
ToolCallCount = result.ToolInvocations.Count,
PromptTokens = result.PromptTokens,
CompletionTokens = result.CompletionTokens
});
return Results.Ok(new
{
success = true,
answer = result.Answer,
toolCalls = result.ToolInvocations.Select(i => new { tool = i.Tool, arguments = i.Arguments }),
toolCallCount = result.ToolInvocations.Count
});
}
catch (Exception ex)
{
return Results.BadRequest(new { success = false, message = ex.Message });
}
});
}
}
public sealed record SupervisorAskRequest(string Question, string? Profile);
@@ -0,0 +1,122 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Threading.Channels;
using IBKRTrader.Core.Logging;
using Microsoft.AspNetCore.Http;
namespace IBKRTrader.Hosting.Web;
/// <summary>
/// Verteilt Log-Ereignisse über Server-Sent Events (SSE) an Web-Browser.
///
/// <para><b>Entscheidung für mehrere Browser-Clients:</b>
/// Jeder verbundene Browser erhält einen eigenen isolierten <see cref="Channel{T}"/> mit begrenzter
/// Kapazität (500 Zeilen, DropOldest). Dadurch verbraucht kein Client unbegrenzt Speicher, und ein
/// stockender oder getrennter Browser blockiert weder andere Clients noch den schreibenden LoggingService.</para>
/// </summary>
public sealed class LogStreamService : IDisposable
{
private const int RingBufferSize = 100;
private const int ClientChannelCapacity = 500;
private readonly LoggingService _logger;
private readonly ConcurrentDictionary<Guid, Channel<LogEntry>> _clients = new();
private readonly ConcurrentQueue<LogEntry> _recentBuffer = new();
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public LogStreamService(LoggingService logger)
{
_logger = logger;
_logger.EntryWritten += OnEntryWritten;
}
/// <summary>Liefert die kürzlich geschriebenen Log-Einträge.</summary>
public IReadOnlyList<object> GetRecent()
{
return _recentBuffer.Select(FormatPayload).ToList();
}
private void OnEntryWritten(LogEntry e)
{
_recentBuffer.Enqueue(e);
while (_recentBuffer.Count > RingBufferSize && _recentBuffer.TryDequeue(out _)) { }
foreach (var (_, channel) in _clients)
{
channel.Writer.TryWrite(e);
}
}
/// <summary>
/// Bedient eine SSE-Verbindung (text/event-stream) für einen verbundenen Browser.
/// </summary>
public async Task HandleStreamAsync(HttpContext context)
{
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache";
context.Response.Headers.Connection = "keep-alive";
var clientId = Guid.NewGuid();
var channel = Channel.CreateBounded<LogEntry>(new BoundedChannelOptions(ClientChannelCapacity)
{
FullMode = BoundedChannelFullMode.DropOldest
});
_clients.TryAdd(clientId, channel);
try
{
// Initialen Puffer nachholen, damit die Ansicht nicht leer startet
foreach (var buffered in _recentBuffer)
{
await WriteSseEventAsync(context, buffered).ConfigureAwait(false);
}
// Live-Stream
while (!context.RequestAborted.IsCancellationRequested)
{
var entry = await channel.Reader.ReadAsync(context.RequestAborted).ConfigureAwait(false);
await WriteSseEventAsync(context, entry).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Browser hat Verbindung getrennt
}
finally
{
_clients.TryRemove(clientId, out _);
}
}
private static async Task WriteSseEventAsync(HttpContext context, LogEntry e)
{
var payload = FormatPayload(e);
var json = JsonSerializer.Serialize(payload, JsonOpts);
var sseMessage = $"data: {json}\n\n";
await context.Response.WriteAsync(sseMessage, context.RequestAborted).ConfigureAwait(false);
await context.Response.Body.FlushAsync(context.RequestAborted).ConfigureAwait(false);
}
private static object FormatPayload(LogEntry e) => new
{
timestamp = e.Timestamp.ToString("HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture),
level = e.Level.ToString(),
module = e.Module,
message = e.Message,
formatted = LoggingService.Format(e),
isError = e.Level == AppLogLevel.Error,
isWarn = e.Level == AppLogLevel.Warn
};
public void Dispose()
{
_logger.EntryWritten -= OnEntryWritten;
_clients.Clear();
}
}
@@ -0,0 +1,189 @@
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Orders;
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Positions;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading;
using IBKRTrader.Core.Workers;
using IBKRTrader.Hosting.Web.Endpoints;
using IBKRTrader.Modules.Accounting.Persistence;
using IBKRTrader.Modules.Accounting.Services;
using IBKRTrader.Modules.CongressTrading.Database;
using IBKRTrader.Modules.Supervisor.Agent;
using IBKRTrader.Modules.Supervisor.Persistence;
using IBKRTrader.Modules.Supervisor.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.Hosting.Web;
/// <summary>
/// Einheitlicher Webserver für Web-UI und REST-API auf Basis von ASP.NET Core Minimal APIs und Kestrel.
///
/// <para>Ersetzt die bisherigen getrennten HttpListener-Dienste (WebserverService und WebApiService).
/// Läuft als permanenter Service in beiden Betriebsformen (Desktop und Daemon).</para>
/// </summary>
public sealed class WebserverWorker : WorkerBase, IDisposable
{
private readonly SettingsService _settings;
private readonly IServiceProvider _rootServices;
private readonly AuthService _authService;
private readonly LogStreamService _logStreamService;
private WorkerEngine? _engine;
private WebApplication? _app;
public override string Name => "WebserverService";
public override string Module => "Core";
public override WorkerType Type => WorkerType.Service;
protected override TimeSpan? Interval => null;
public WebserverWorker(
LoggingService logger,
IDbContextFactory<CoreDbContext> db,
SettingsService settings,
IServiceProvider rootServices)
: base(logger, db)
{
_settings = settings;
_rootServices = rootServices;
_authService = new AuthService(settings);
_logStreamService = new LogStreamService(logger);
Info.Active = settings.Settings.Webserver.Enabled;
Info.RunEvery = "Service";
Info.Info = settings.Settings.Webserver.Enabled
? $"Port {settings.Settings.Webserver.Port}"
: "Deaktiviert";
}
/// <summary>Setzt die WorkerEngine nach dem Bau des Hosts (zirkuläre Abhängigkeit auflösen).</summary>
public void SetEngine(WorkerEngine engine) => _engine = engine;
protected override async Task ExecuteAsync(CancellationToken ct)
{
if (!_settings.Settings.Webserver.Enabled)
{
Info.Info = "Deaktiviert";
Info.Status = WorkerStatus.Stopped;
return;
}
var port = _settings.Settings.Webserver.Port;
var remote = _settings.Settings.Webserver.AllowRemoteAccess;
var host = remote ? "0.0.0.0" : "127.0.0.1";
var url = $"http://{host}:{port}";
var wwwroot = Path.Combine(AppContext.BaseDirectory, "wwwroot");
Directory.CreateDirectory(wwwroot);
var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions
{
ContentRootPath = AppContext.BaseDirectory,
WebRootPath = wwwroot,
Args = []
});
builder.WebHost.UseKestrel(kestrel =>
{
if (remote) kestrel.ListenAnyIP(port);
else kestrel.ListenLocalhost(port);
});
// Grundlegende Web-Dienste
builder.Services.AddRouting();
builder.Services.AddSingleton(_authService);
builder.Services.AddSingleton(_logStreamService);
// Core-Dienste weiterreichen
builder.Services.AddSingleton(_settings);
builder.Services.AddSingleton(Logger);
builder.Services.AddSingleton(_rootServices.GetRequiredService<DashboardService>());
builder.Services.AddSingleton(_rootServices.GetRequiredService<PositionManager>());
builder.Services.AddSingleton(_rootServices.GetRequiredService<OrderManager>());
builder.Services.AddSingleton(_rootServices.GetRequiredService<IConfiguration>());
builder.Services.AddSingleton(_rootServices.GetServices<IModule>());
builder.Services.AddSingleton(sp => _engine ?? _rootServices.GetRequiredService<WorkerEngine>());
// Optionale Modul-Dienste weiterreichen
ForwardOptional<CongressRepository>(builder.Services);
ForwardOptional<IPortfolioService>(builder.Services);
ForwardOptional<ILedgerRepository>(builder.Services);
ForwardOptional<IIngestRunRepository>(builder.Services);
ForwardOptional<AccountingReportService>(builder.Services);
ForwardOptional<AccountingIngestService>(builder.Services);
ForwardOptional<SupervisorAgent>(builder.Services);
ForwardOptional<DossierService>(builder.Services);
ForwardOptional<ISupervisorReportRepository>(builder.Services);
_app = builder.Build();
_app.UseDefaultFiles();
_app.UseStaticFiles();
// Globaler Endpunkt-Filter für /api: Authentifizierung erzwingen
var api = _app.MapGroup("/api");
api.AddEndpointFilter(async (invocationContext, next) =>
{
var path = invocationContext.HttpContext.Request.Path.Value ?? "";
if (path.Equals("/api/status", StringComparison.OrdinalIgnoreCase) ||
path.Equals("/api/auth/login", StringComparison.OrdinalIgnoreCase) ||
path.Equals("/api/auth/status", StringComparison.OrdinalIgnoreCase))
{
return await next(invocationContext);
}
var auth = invocationContext.HttpContext.RequestServices.GetRequiredService<AuthService>();
if (!auth.IsAuthenticated(invocationContext.HttpContext))
{
return Results.Json(new { error = "Authentifizierung erforderlich", loginRequired = true }, statusCode: 401);
}
return await next(invocationContext);
});
CoreEndpoints.Map(api);
ModuleEndpoints.Map(api, _rootServices);
try
{
await _app.StartAsync(ct).ConfigureAwait(false);
Logger.Info(Module, $"Webserver gestartet auf {url} (Web-UI & REST-API)");
Info.Info = url;
Info.Status = WorkerStatus.Running;
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) { }
finally
{
try
{
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await _app.StopAsync(stopCts.Token).ConfigureAwait(false);
}
catch { /* Beenden tolerieren */ }
Logger.Info(Module, "Webserver gestoppt.");
Info.Info = "Gestoppt";
Info.Status = WorkerStatus.Stopped;
}
}
private void ForwardOptional<T>(IServiceCollection services) where T : class
{
var instance = _rootServices.GetService<T>();
if (instance != null) services.AddSingleton(instance);
}
public void Dispose()
{
_logStreamService.Dispose();
_app?.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
}
+513
View File
@@ -0,0 +1,513 @@
/* ─── IBKRTrader Web-UI — Design System (Avalonia / Polytrader Theme) ─── */
:root {
--bg-app: #f3f4f6;
--bg-card: #ffffff;
--bg-toolbar: #fafafa;
--bg-statusbar: #fafafa;
--bg-hover: #f1f5f9;
--bg-selected: #e2e8f0;
--border-color: #dddddd;
--border-subtle: #e5e7eb;
--text-main: #1f2937;
--text-muted: #666666;
--text-light: #999999;
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-success: #16a34a;
--color-success-bg: #dcfce7;
--color-warning: #ea580c;
--color-warning-bg: #ffedd5;
--color-danger: #dc2626;
--color-danger-bg: #fee2e2;
--font-family: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-family);
font-size: 13px;
color: var(--text-main);
background-color: var(--bg-app);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ─── App Header & Navigation ─────────────────────────────────────────── */
.app-header {
background: #ffffff;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 48px;
user-select: none;
flex-shrink: 0;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-title {
font-size: 16px;
font-weight: 600;
letter-spacing: -0.2px;
}
.brand-badge {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 9999px;
background: var(--bg-app);
color: var(--text-muted);
border: 1px solid var(--border-subtle);
}
.badge-paper {
background: var(--color-success-bg);
color: var(--color-success);
border-color: #86efac;
}
.badge-live {
background: var(--color-danger-bg);
color: var(--color-danger);
border-color: #fca5a5;
font-weight: 600;
}
.nav-tabs {
display: flex;
gap: 4px;
overflow-x: auto;
scrollbar-width: none;
}
.nav-tab {
background: none;
border: none;
padding: 8px 14px;
font-family: inherit;
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s ease;
white-space: nowrap;
}
.nav-tab:hover {
color: var(--text-main);
background: var(--bg-hover);
}
.nav-tab.active {
color: var(--color-primary);
font-weight: 600;
border-bottom-color: var(--color-primary);
background: transparent;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
/* ─── App Body Layout ─────────────────────────────────────────────────── */
.app-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.view-container {
display: none;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--bg-card);
}
.view-container.active {
display: flex;
}
/* ─── Toolbars ────────────────────────────────────────────────────────── */
.toolbar {
background: var(--bg-toolbar);
border-bottom: 1px solid var(--border-color);
padding: 6px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
flex-shrink: 0;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
/* ─── Buttons & Controls ──────────────────────────────────────────────── */
.btn {
background: #ffffff;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 5px 12px;
font-family: inherit;
font-size: 12px;
color: var(--text-main);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.15s ease;
user-select: none;
}
.btn:hover {
background: var(--bg-hover);
border-color: #bbb;
}
.btn:active {
background: var(--bg-selected);
}
.btn-primary {
background: var(--color-primary);
border-color: var(--color-primary-hover);
color: #ffffff;
}
.btn-primary:hover {
background: var(--color-primary-hover);
border-color: #1e40af;
}
.btn-danger {
background: var(--color-danger);
border-color: #b91c1c;
color: #ffffff;
}
.btn-danger:hover {
background: #b91c1c;
}
.input, select {
background: #ffffff;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 4px 8px;
font-family: inherit;
font-size: 12px;
color: var(--text-main);
outline: none;
}
.input:focus, select:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
}
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-main);
cursor: pointer;
user-select: none;
}
/* ─── KPI Panels & Cards (App.axaml style) ────────────────────────────── */
.kpi-panel {
display: flex;
flex-wrap: wrap;
padding: 12px;
gap: 8px;
background: #ffffff;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.kpi-card {
background: #f5f5f5;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 10px 14px;
min-width: 160px;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.kpi-caption {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.kpi-value {
font-size: 20px;
font-weight: 600;
color: var(--text-main);
font-feature-settings: "tnum";
}
/* ─── DataGrid / Tables (Avalonia DataGrid style) ──────────────────────── */
.table-container {
flex: 1;
overflow: auto;
background: #ffffff;
position: relative;
}
.datagrid {
width: 100%;
border-collapse: collapse;
font-size: 12px;
text-align: left;
}
.datagrid th {
background: #fafafa;
color: var(--text-muted);
font-weight: 600;
padding: 6px 10px;
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 10;
white-space: nowrap;
user-select: none;
}
.datagrid td {
padding: 5px 10px;
border-bottom: 1px solid #eeeeee;
color: var(--text-main);
white-space: nowrap;
font-feature-settings: "tnum";
}
.datagrid tbody tr {
cursor: pointer;
height: 26px;
}
.datagrid tbody tr:hover {
background: var(--bg-hover);
}
.datagrid tbody tr.selected {
background: #e0e7ff !important;
color: #1e3a8a;
}
.datagrid .text-right {
text-align: right;
}
.datagrid .text-center {
text-align: center;
}
/* Status Badges in Tables */
.badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.badge-ok { background: #dcfce7; color: #15803d; }
.badge-warn { background: #fef3c7; color: #b45309; }
.badge-error { background: #fee2e2; color: #b91c1c; }
/* ─── Log Viewer ──────────────────────────────────────────────────────── */
.log-scroller {
flex: 1;
background: #1e1e1e;
color: #d4d4d4;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.5;
padding: 8px 12px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.log-line {
padding: 1px 0;
}
.log-info { color: #96d296; }
.log-warn { color: #ffbe3c; }
.log-error { color: #ff5050; font-weight: 600; }
/* ─── Settings Tree Form ──────────────────────────────────────────────── */
.settings-scroll {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.settings-section {
background: #ffffff;
border: 1px solid var(--border-color);
border-radius: 4px;
margin-bottom: 12px;
overflow: hidden;
}
.settings-section-header {
background: #fafafa;
padding: 8px 14px;
font-weight: 600;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.settings-section-body {
padding: 10px 14px;
display: grid;
grid-template-columns: 240px 1fr;
row-gap: 8px;
column-gap: 16px;
align-items: center;
}
.settings-label {
font-weight: 500;
color: var(--text-main);
}
.settings-desc {
font-size: 11px;
color: var(--text-muted);
margin-top: 1px;
}
/* ─── Statusbar ───────────────────────────────────────────────────────── */
.app-statusbar {
background: var(--bg-statusbar);
border-top: 1px solid var(--border-color);
height: 28px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
font-size: 11px;
color: var(--text-muted);
user-select: none;
flex-shrink: 0;
}
/* ─── Login Overlay & Dialog ──────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(2px);
}
.login-card {
background: #ffffff;
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);
width: 100%;
max-width: 380px;
padding: 28px;
border: 1px solid var(--border-color);
}
.login-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 6px;
text-align: center;
}
.login-subtitle {
font-size: 12px;
color: var(--text-muted);
text-align: center;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 12px;
font-weight: 500;
margin-bottom: 6px;
}
.form-input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 13px;
outline: none;
}
.form-input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
}
.login-error {
color: var(--color-danger);
font-size: 12px;
margin-top: 8px;
min-height: 18px;
text-align: center;
}
+68
View File
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IBKRTrader</title>
<link rel="stylesheet" href="css/app.css" />
</head>
<body>
<!-- App Header -->
<header class="app-header">
<div class="brand">
<span class="brand-title">IBKRTrader</span>
<span id="app-mode-badge" class="brand-badge badge-paper">PAPER</span>
</div>
<nav class="nav-tabs" id="main-nav">
<button class="nav-tab active" data-view="dashboard">Dashboard</button>
<button class="nav-tab" data-view="workers">Worker</button>
<button class="nav-tab" data-view="positions">Positionen</button>
<button class="nav-tab" data-view="orders">Orders</button>
<button class="nav-tab" data-view="congress">Congress</button>
<button class="nav-tab" data-view="accounting">Accounting</button>
<button class="nav-tab" data-view="supervisor">Supervisor</button>
<button class="nav-tab" data-view="logs">Logs</button>
<button class="nav-tab" data-view="settings">Einstellungen</button>
</nav>
<div style="display: flex; align-items: center; gap: 8px;">
<button id="logout-btn" class="btn" style="padding: 4px 10px; font-size: 11px;">🔒 Abmelden</button>
</div>
</header>
<!-- Main View Container -->
<main id="view-container" style="flex: 1; display: flex; flex-direction: column; overflow: hidden;"></main>
<!-- Status Bar -->
<footer class="app-statusbar">
<span id="status-bar-message">Bereit.</span>
<div style="display: flex; align-items: center; gap: 14px;">
<span id="status-bar-connection" style="color: var(--color-success);">● Verbunden</span>
<span id="status-bar-clock" style="font-family: var(--font-mono); font-weight: 500;">--:--:-- UTC</span>
</div>
</footer>
<!-- Login Modal Overlay -->
<div id="login-modal" class="modal-overlay" style="display: none;">
<div class="login-card">
<div class="login-title">IBKRTrader Login</div>
<div class="login-subtitle">Geben Sie das Zugriffs-Token ein (settings.json &gt; Webserver &gt; AuthToken)</div>
<div class="form-group">
<label for="login-token-input" class="form-label">Auth-Token</label>
<input type="password" id="login-token-input" class="form-input" placeholder="Token eingeben …" autocomplete="current-password" autofocus />
</div>
<div id="login-error-text" class="login-error"></div>
<div style="margin-top: 20px;">
<button id="login-submit-btn" class="btn btn-primary" style="width: 100%; padding: 10px; font-weight: 600;">Anmelden</button>
</div>
</div>
</div>
<script type="module" src="js/app.js"></script>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
// ─── API Client mit Authentifizierungs-Interceptor ───
export class ApiClient {
constructor() {
this.token = sessionStorage.getItem('ibkr_token') || '';
this.onUnauthorized = null;
}
setToken(token) {
this.token = token;
if (token) {
sessionStorage.setItem('ibkr_token', token);
} else {
sessionStorage.removeItem('ibkr_token');
}
}
hasToken() {
return !!this.token;
}
async request(url, options = {}) {
const headers = {
'Accept': 'application/json',
...options.headers
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
if (options.body && typeof options.body === 'object' && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(options.body);
}
try {
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
if (this.onUnauthorized) {
this.onUnauthorized();
}
throw new Error('Authentifizierung erforderlich (401)');
}
if (!response.ok) {
let errMessage = `HTTP ${response.status}`;
try {
const errData = await response.json();
if (errData.error || errData.message) {
errMessage = errData.error || errData.message;
}
} catch (_) {}
throw new Error(errMessage);
}
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return await response.json();
}
return response;
} catch (err) {
throw err;
}
}
get(url) {
return this.request(url, { method: 'GET' });
}
post(url, body) {
return this.request(url, { method: 'POST', body });
}
}
export const api = new ApiClient();
+159
View File
@@ -0,0 +1,159 @@
// ─── Main Web-UI Application Router ───
import { api } from './api.js';
import { auth } from './auth.js';
import { DashboardView } from './views/dashboard.js';
import { WorkersView } from './views/workers.js';
import { PositionsView } from './views/positions.js';
import { OrdersView } from './views/orders.js';
import { CongressTradingView } from './views/congress.js';
import { AccountingView } from './views/accounting.js';
import { SupervisorView } from './views/supervisor.js';
import { LogsView } from './views/logs.js';
import { SettingsView } from './views/settings.js';
class App {
constructor() {
this.container = document.getElementById('view-container');
this.statusMsgEl = document.getElementById('status-bar-message');
this.clockEl = document.getElementById('status-bar-clock');
this.modeBadgeEl = document.getElementById('app-mode-badge');
this.currentView = null;
this.currentViewName = null;
this.views = {
dashboard: DashboardView,
workers: WorkersView,
positions: PositionsView,
orders: OrdersView,
congress: CongressTradingView,
accounting: AccountingView,
supervisor: SupervisorView,
logs: LogsView,
settings: SettingsView
};
this.setStatus = this.setStatus.bind(this);
}
init() {
this.startClock();
this.wireNav();
auth.onLoginSuccess = async () => {
await this.updateStatusBadge();
await this.switchView(this.currentViewName || 'dashboard');
};
// Initial auth check
auth.checkInitialAuth().then(async (ok) => {
if (ok) {
await this.updateStatusBadge();
const hash = window.location.hash.replace('#', '') || 'dashboard';
const initialView = this.views[hash] ? hash : 'dashboard';
await this.switchView(initialView);
}
});
window.addEventListener('hashchange', () => {
const viewName = window.location.hash.replace('#', '');
if (this.views[viewName] && viewName !== this.currentViewName) {
this.switchView(viewName);
}
});
}
startClock() {
const updateClock = () => {
const now = new Date();
const h = String(now.getUTCHours()).padStart(2, '0');
const m = String(now.getUTCMinutes()).padStart(2, '0');
const s = String(now.getUTCSeconds()).padStart(2, '0');
if (this.clockEl) {
this.clockEl.textContent = `${h}:${m}:${s} UTC`;
}
};
updateClock();
setInterval(updateClock, 1000);
}
wireNav() {
const navTabs = document.querySelectorAll('#main-nav .nav-tab');
navTabs.forEach(btn => {
btn.addEventListener('click', () => {
const viewName = btn.getAttribute('data-view');
if (viewName) {
window.location.hash = viewName;
this.switchView(viewName);
}
});
});
}
setStatus(msg) {
if (this.statusMsgEl) {
this.statusMsgEl.textContent = msg || 'Bereit.';
}
}
async updateStatusBadge() {
try {
const status = await api.get('/api/status');
if (status && this.modeBadgeEl) {
const mode = status.mode || 'PAPER';
this.modeBadgeEl.textContent = mode.toUpperCase();
if (mode.toLowerCase() === 'live') {
this.modeBadgeEl.className = 'brand-badge badge-live';
} else {
this.modeBadgeEl.className = 'brand-badge badge-paper';
}
}
} catch (_) {}
}
async switchView(viewName) {
if (!this.views[viewName]) {
viewName = 'dashboard';
}
if (this.currentView && typeof this.currentView.destroy === 'function') {
try {
this.currentView.destroy();
} catch (err) {
console.warn('Fehler bei cleanup des vorherigen Views:', err);
}
}
// Update nav tabs highlight
const navTabs = document.querySelectorAll('#main-nav .nav-tab');
navTabs.forEach(btn => {
if (btn.getAttribute('data-view') === viewName) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
this.currentViewName = viewName;
this.container.innerHTML = '<div style="padding: 20px; color: var(--text-muted); font-size: 12px;">Lade Ansicht …</div>';
const ViewClass = this.views[viewName];
this.currentView = new ViewClass(this.container, this.setStatus);
try {
await this.currentView.render();
} catch (err) {
console.error(`Fehler beim Rendern von View '${viewName}':`, err);
this.container.innerHTML = `
<div style="padding: 24px; color: var(--color-danger);">
<h3>Fehler beim Laden der Ansicht</h3>
<p style="margin-top: 8px; font-family: var(--font-mono); font-size: 12px;">${err.message}</p>
</div>
`;
}
}
}
// Start application
const app = new App();
document.addEventListener('DOMContentLoaded', () => app.init());
+101
View File
@@ -0,0 +1,101 @@
// ─── Authentifizierungs-Verwaltung ───
import { api } from './api.js';
export class AuthManager {
constructor() {
this.onLoginSuccess = null;
this.modal = document.getElementById('login-modal');
this.tokenInput = document.getElementById('login-token-input');
this.errorText = document.getElementById('login-error-text');
this.submitBtn = document.getElementById('login-submit-btn');
this.logoutBtn = document.getElementById('logout-btn');
this.wireEvents();
}
wireEvents() {
if (this.submitBtn) {
this.submitBtn.addEventListener('click', () => this.handleLogin());
}
if (this.tokenInput) {
this.tokenInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') this.handleLogin();
});
}
if (this.logoutBtn) {
this.logoutBtn.addEventListener('click', () => this.handleLogout());
}
api.onUnauthorized = () => this.showLoginModal();
}
showLoginModal() {
if (this.modal) {
this.modal.style.display = 'flex';
this.errorText.textContent = '';
if (this.tokenInput) {
this.tokenInput.value = '';
setTimeout(() => this.tokenInput.focus(), 50);
}
}
}
hideLoginModal() {
if (this.modal) {
this.modal.style.display = 'none';
this.errorText.textContent = '';
}
}
async checkInitialAuth() {
try {
const status = await api.get('/api/auth/status');
if (status && status.authenticated) {
this.hideLoginModal();
return true;
}
} catch (_) {}
this.showLoginModal();
return false;
}
async handleLogin() {
const token = this.tokenInput ? this.tokenInput.value.trim() : '';
if (!token) {
this.errorText.textContent = 'Bitte Auth-Token eingeben.';
return;
}
this.submitBtn.disabled = true;
this.errorText.textContent = 'Prüfe Token …';
try {
const res = await api.post('/api/auth/login', { token });
if (res && res.ok) {
api.setToken(res.token || token);
this.hideLoginModal();
if (this.onLoginSuccess) {
this.onLoginSuccess();
}
} else {
this.errorText.textContent = res.error || 'Ungültiger Token.';
}
} catch (err) {
this.errorText.textContent = err.message || 'Anmeldung fehlgeschlagen.';
} finally {
this.submitBtn.disabled = false;
}
}
async handleLogout() {
try {
await api.post('/api/auth/logout');
} catch (_) {}
api.setToken('');
this.showLoginModal();
}
}
export const auth = new AuthManager();
@@ -0,0 +1,392 @@
// ─── Accounting View ───
import { api } from '../api.js';
export class AccountingView {
constructor(container, setStatus) {
this.container = container;
this.setStatus = setStatus;
this.currentSubTab = 'overview';
this.accounts = [];
}
async render() {
const today = new Date().toISOString().split('T')[0];
const monthAgo = new Date(Date.now() - 30 * 86400000).toISOString().split('T')[0];
this.container.innerHTML = `
<div class="toolbar" style="flex-wrap: wrap; gap: 8px;">
<div class="toolbar-group" style="flex-wrap: wrap; gap: 8px;">
<label style="font-weight: 500; font-size: 12px; display: flex; align-items: center; gap: 4px;">
Von: <input type="date" id="acc-from" class="input" value="${monthAgo}" style="width: 125px; padding: 3px 6px;" />
</label>
<label style="font-weight: 500; font-size: 12px; display: flex; align-items: center; gap: 4px;">
Bis: <input type="date" id="acc-to" class="input" value="${today}" style="width: 125px; padding: 3px 6px;" />
</label>
<label style="font-weight: 500; font-size: 12px; display: flex; align-items: center; gap: 4px;">
Konto:
<select id="acc-account" class="select" style="min-width: 130px; padding: 3px 6px;">
<option value="(alle)">(alle)</option>
</select>
</label>
<label style="font-weight: 500; font-size: 12px; display: flex; align-items: center; gap: 4px;">
Währung:
<select id="acc-currency" class="select" style="min-width: 80px; padding: 3px 6px;">
<option value="USD" selected>USD</option>
<option value="EUR">EUR</option>
<option value="CHF">CHF</option>
<option value="GBP">GBP</option>
</select>
</label>
<button id="acc-refresh-btn" class="btn">🔄 Aktualisieren</button>
</div>
<div id="acc-status-text" style="color: var(--text-muted); font-size: 11px;"></div>
</div>
<!-- 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="overview" id="acc-tab-overview">Übersicht / BWA</button>
<button class="nav-tab" data-subtab="ledger" id="acc-tab-ledger">Ledger</button>
<button class="nav-tab" data-subtab="tax" id="acc-tab-tax">Steuer</button>
<button class="nav-tab" data-subtab="export" id="acc-tab-export">Abrechnung / Export</button>
<button class="nav-tab" data-subtab="runs" id="acc-tab-runs">Abruf / Status</button>
</div>
<div id="acc-subcontent" style="flex: 1; display: flex; flex-direction: column; overflow: hidden;">
<!-- Subtab content injected here -->
</div>
`;
// Bind filters
document.getElementById('acc-refresh-btn').addEventListener('click', () => this.refreshCurrentSubTab());
document.getElementById('acc-account').addEventListener('change', () => this.refreshCurrentSubTab());
document.getElementById('acc-currency').addEventListener('change', () => this.refreshCurrentSubTab());
document.getElementById('acc-from').addEventListener('change', () => this.refreshCurrentSubTab());
document.getElementById('acc-to').addEventListener('change', () => this.refreshCurrentSubTab());
// Subtab switching
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.loadAccounts();
await this.renderSubTab();
}
async loadAccounts() {
try {
const accounts = await api.get('/api/modules/accounting/accounts');
if (Array.isArray(accounts)) {
this.accounts = accounts;
const select = document.getElementById('acc-account');
if (select) {
select.innerHTML = '<option value="(alle)">(alle)</option>' +
accounts.map(a => `<option value="${a}">${a}</option>`).join('');
}
}
} catch (e) {
console.warn('Fehler beim Laden der Konten:', e);
}
}
getFilters() {
const from = document.getElementById('acc-from')?.value || '';
const to = document.getElementById('acc-to')?.value || '';
const account = document.getElementById('acc-account')?.value || '(alle)';
const currency = document.getElementById('acc-currency')?.value || 'USD';
return { from, to, account, currency };
}
async renderSubTab() {
const sub = document.getElementById('acc-subcontent');
if (!sub) return;
if (this.currentSubTab === 'overview') {
sub.innerHTML = `
<div class="kpi-panel" id="acc-kpis">
<div class="kpi-card"><div class="kpi-caption">Netto-Ergebnis</div><div class="kpi-value" id="kpi-acc-net">–</div></div>
<div class="kpi-card"><div class="kpi-caption">Handelsvolumen</div><div class="kpi-value" id="kpi-acc-vol">–</div></div>
<div class="kpi-card"><div class="kpi-caption">Dividenden</div><div class="kpi-value" id="kpi-acc-div">–</div></div>
<div class="kpi-card"><div class="kpi-caption">Gebühren / Fees</div><div class="kpi-value" id="kpi-acc-fees">–</div></div>
<div class="kpi-card"><div class="kpi-caption">Endsaldo</div><div class="kpi-value" id="kpi-acc-balance">–</div></div>
<div class="kpi-card"><div class="kpi-caption">Trades / Buchungen</div><div class="kpi-value" id="kpi-acc-counts">–</div></div>
</div>
<div id="acc-currency-note" style="padding: 0 14px 6px; font-size: 11px; color: var(--text-muted);"></div>
<div class="table-container">
<div style="padding: 6px 14px; font-weight: 600; color: var(--text-muted); font-size: 12px; text-transform: uppercase;">
Monatsvergleich
</div>
<table class="datagrid">
<thead>
<tr>
<th style="width: 100px;">Monat</th>
<th class="text-right">Anfangssaldo</th>
<th class="text-right">Einzahlungen</th>
<th class="text-right">Auszahlungen</th>
<th class="text-right">Volumen</th>
<th class="text-right">Fees</th>
<th class="text-right">Ergebnis</th>
<th class="text-right">Endsaldo</th>
</tr>
</thead>
<tbody id="acc-monthly-tbody">
<tr><td colspan="8" style="text-align:center; color: var(--text-muted);">Lade Monatsvergleich …</td></tr>
</tbody>
</table>
</div>
`;
await this.loadOverview();
} else if (this.currentSubTab === 'ledger') {
sub.innerHTML = `
<div class="table-container">
<table class="datagrid">
<thead>
<tr>
<th style="width: 130px;">Zeit (UTC)</th>
<th style="width: 100px;">Konto</th>
<th style="width: 100px;">Typ</th>
<th style="width: 60px;">Side</th>
<th style="width: 90px;">Symbol</th>
<th style="width: 70px;">Währung</th>
<th class="text-right" style="width: 80px;">Menge</th>
<th class="text-right" style="width: 90px;">Preis</th>
<th class="text-right" style="width: 100px;">Brutto</th>
<th class="text-right" style="width: 80px;">Fee</th>
<th class="text-right" style="width: 100px;">Netto</th>
<th>Transaktion</th>
</tr>
</thead>
<tbody id="acc-ledger-tbody">
<tr><td colspan="12" style="text-align:center; color: var(--text-muted);">Lade Ledger …</td></tr>
</tbody>
</table>
</div>
`;
await this.loadLedger();
} else if (this.currentSubTab === 'tax') {
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;">Steuerliche Einordnung</h3>
<p style="margin-bottom: 12px;">
Die steuerliche Einordnung ist noch offen (Jurisdiktion nicht festgelegt).
</p>
<p style="margin-bottom: 12px;">
Der neutrale Ledger und die Periodenabrechnung sind davon unabhängig gültig. Eine konkrete Steuerschicht (z. B. DE-Kapitalertragsteuer oder US Form 8949 / Schedule D) wird hier später als klar dokumentierte, prüfbare Rechenschicht ergänzt.
</p>
<div style="background: #fffbeb; border: 1px solid #fde68a; padding: 12px; border-radius: 4px; color: #b45309; font-size: 12px;">
<b>Hinweis:</b> Dies ist keine Steuerberatung. Alle Daten basieren auf den Buchungseinträgen des Ledgers.
</div>
</div>
`;
} else if (this.currentSubTab === 'export') {
const f = this.getFilters();
const params = new URLSearchParams({ account: f.account, from: f.from, to: f.to, currency: f.currency });
sub.innerHTML = `
<div style="padding: 24px; max-width: 600px;">
<h3 style="margin-bottom: 8px; font-size: 15px;">Abrechnung & Export</h3>
<p style="color: var(--text-muted); margin-bottom: 20px; font-size: 12px;">
Exportiert die aktuelle Auswahl (Zeitraum: ${f.from} bis ${f.to} | Konto: ${f.account} | Währung: ${f.currency}):
</p>
<div style="display: flex; flex-direction: column; gap: 12px;">
<a href="/api/modules/accounting/export/ledger.csv?${params}" target="_blank" class="btn" style="text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 16px;">
📄 Ledger als CSV herunterladen
</a>
<a href="/api/modules/accounting/export/statement.csv?${params}" target="_blank" class="btn" style="text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 16px;">
📊 Abrechnung als CSV herunterladen
</a>
<a href="/api/modules/accounting/export/statement.pdf?${params}" target="_blank" class="btn btn-primary" style="text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 10px 16px;">
📑 Abrechnung als PDF herunterladen
</a>
</div>
</div>
`;
} else if (this.currentSubTab === 'runs') {
sub.innerHTML = `
<div class="toolbar" style="border-top: none;">
<div class="toolbar-group">
<button id="acc-ingest-inc-btn" class="btn">📥 Inkrementell abrufen</button>
<button id="acc-ingest-backfill-btn" class="btn">🔄 Backfill (voll)</button>
</div>
<div id="acc-ingest-msg" style="color: var(--text-muted); font-size: 11px;"></div>
</div>
<div class="table-container">
<table class="datagrid">
<thead>
<tr>
<th style="width: 100px;">Konto</th>
<th style="width: 130px;">Start</th>
<th style="width: 130px;">Ende</th>
<th style="width: 80px; text-align: center;">Backfill</th>
<th class="text-right" style="width: 70px;">Neu</th>
<th class="text-right" style="width: 80px;">Duplikate</th>
<th class="text-center" style="width: 60px;">OK</th>
<th class="text-right" style="width: 100px;">Delta</th>
<th>Meldung</th>
</tr>
</thead>
<tbody id="acc-runs-tbody">
<tr><td colspan="9" style="text-align:center; color: var(--text-muted);">Lade Abruf-Historie …</td></tr>
</tbody>
</table>
</div>
`;
document.getElementById('acc-ingest-inc-btn').addEventListener('click', () => this.runIngest(false));
document.getElementById('acc-ingest-backfill-btn').addEventListener('click', () => this.runIngest(true));
await this.loadRuns();
}
}
async refreshCurrentSubTab() {
await this.renderSubTab();
}
async loadOverview() {
const f = this.getFilters();
this.setStatus('Lade Accounting-Übersicht …');
try {
const params = new URLSearchParams({ account: f.account, from: f.from, to: f.to, currency: f.currency });
const data = await api.get(`/api/modules/accounting/overview?${params}`);
if (!data) return;
const s = data.statement || {};
const curr = data.currencyCode || f.currency;
const F = (v) => Number(v || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ' + curr;
const netEl = document.getElementById('kpi-acc-net');
if (netEl) {
netEl.textContent = F(s.netTradingResult);
netEl.style.color = (s.netTradingResult >= 0) ? 'var(--color-success)' : 'var(--color-danger)';
}
document.getElementById('kpi-acc-vol').textContent = F(s.tradeVolume);
document.getElementById('kpi-acc-div').textContent = F(s.dividends);
document.getElementById('kpi-acc-fees').textContent = F(s.fees);
document.getElementById('kpi-acc-balance').textContent = F(s.closingBalance);
document.getElementById('kpi-acc-counts').textContent = `${s.tradeCount || 0} / ${s.entryCount || 0}`;
const noteEl = document.getElementById('acc-currency-note');
if (noteEl) noteEl.textContent = data.currencyNote || '';
const tbody = document.getElementById('acc-monthly-tbody');
const monthly = data.monthly || [];
if (tbody) {
if (monthly.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" style="text-align:center; color: var(--text-muted);">Keine Monatsdaten im gewählten Zeitraum.</td></tr>`;
} else {
tbody.innerHTML = monthly.map(m => `
<tr>
<td style="font-weight: 600;">${m.month}</td>
<td class="text-right">${F(m.openingBalance)}</td>
<td class="text-right">${F(m.deposits)}</td>
<td class="text-right">${F(m.withdrawals)}</td>
<td class="text-right">${F(m.tradeVolume)}</td>
<td class="text-right">${F(m.fees)}</td>
<td class="text-right" style="font-weight: 500; color: ${m.netTradingResult >= 0 ? 'var(--color-success)' : 'var(--color-danger)'}">${F(m.netTradingResult)}</td>
<td class="text-right" style="font-weight: 600;">${F(m.closingBalance)}</td>
</tr>
`).join('');
}
}
this.setStatus(`Übersicht aktualisiert (${monthly.length} Monate).`);
} catch (e) {
console.error(e);
this.setStatus(`Fehler: ${e.message}`);
}
}
async loadLedger() {
const f = this.getFilters();
this.setStatus('Lade Ledger-Einträge …');
try {
const params = new URLSearchParams({ account: f.account, from: f.from, to: f.to, limit: 1000 });
const entries = await api.get(`/api/modules/accounting/ledger?${params}`);
const tbody = document.getElementById('acc-ledger-tbody');
if (!tbody) return;
if (!Array.isArray(entries) || entries.length === 0) {
tbody.innerHTML = `<tr><td colspan="12" style="text-align:center; color: var(--text-muted);">Keine Ledger-Einträge im gewählten Zeitraum.</td></tr>`;
} else {
tbody.innerHTML = entries.map(e => `
<tr>
<td>${e.timestamp}</td>
<td><b>${e.accountId}</b></td>
<td>${e.eventType}</td>
<td>${e.side || '–'}</td>
<td style="font-weight: 500;">${e.symbol || '–'}</td>
<td>${e.currency || '–'}</td>
<td class="text-right">${e.quantity !== null && e.quantity !== undefined ? Number(e.quantity).toLocaleString('de-DE') : '–'}</td>
<td class="text-right">${e.priceNative !== null && e.priceNative !== undefined ? Number(e.priceNative).toLocaleString('de-DE', { minimumFractionDigits: 2 }) : '–'}</td>
<td class="text-right">${Number(e.grossBase || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })}</td>
<td class="text-right">${Number(e.feeBase || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })}</td>
<td class="text-right" style="font-weight: 500;">${Number(e.netBase || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })}</td>
<td style="font-family: var(--font-mono); font-size: 11px;">${e.transactionId || '–'}</td>
</tr>
`).join('');
}
this.setStatus(`Ledger geladen (${entries ? entries.length : 0} Einträge).`);
} catch (e) {
console.error(e);
this.setStatus(`Fehler: ${e.message}`);
}
}
async loadRuns() {
const f = this.getFilters();
this.setStatus('Lade Abruf-Historie …');
try {
const params = new URLSearchParams({ account: f.account, limit: 100 });
const runs = await api.get(`/api/modules/accounting/runs?${params}`);
const tbody = document.getElementById('acc-runs-tbody');
if (!tbody) return;
if (!Array.isArray(runs) || runs.length === 0) {
tbody.innerHTML = `<tr><td colspan="9" style="text-align:center; color: var(--text-muted);">Keine Abrufe vorhanden.</td></tr>`;
} else {
tbody.innerHTML = runs.map(r => `
<tr>
<td><b>${r.accountId}</b></td>
<td>${r.startedAt}</td>
<td>${r.finishedAt}</td>
<td class="text-center">${r.backfill ? 'Ja' : 'Nein'}</td>
<td class="text-right">${Number(r.newEntries || 0).toLocaleString('de-DE')}</td>
<td class="text-right">${Number(r.duplicateEntries || 0).toLocaleString('de-DE')}</td>
<td class="text-center">${r.success ? '<span class="badge badge-ok">OK</span>' : '<span class="badge badge-error">Fehler</span>'}</td>
<td class="text-right">${Number(r.balanceDeltaBase || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })}</td>
<td>${r.message || '–'}</td>
</tr>
`).join('');
}
this.setStatus(`Abrufe geladen (${runs ? runs.length : 0} Läufe).`);
} catch (e) {
console.error(e);
this.setStatus(`Fehler: ${e.message}`);
}
}
async runIngest(backfill) {
const msgEl = document.getElementById('acc-ingest-msg');
if (msgEl) msgEl.textContent = backfill ? 'Führe Backfill durch …' : 'Führe inkrementellen Abruf durch …';
this.setStatus('Starte Ingest …');
try {
const res = await api.post(`/api/modules/accounting/ingest?backfill=${backfill}`);
if (res && res.success) {
if (msgEl) msgEl.textContent = res.message || 'Abruf abgeschlossen.';
} else {
if (msgEl) msgEl.textContent = `Fehler: ${res?.message || 'Unbekannt'}`;
}
await this.loadRuns();
} catch (e) {
if (msgEl) msgEl.textContent = `Fehler: ${e.message}`;
}
}
}
@@ -0,0 +1,114 @@
// ─── Congress Trading View ───
import { api } from '../api.js';
export class CongressTradingView {
constructor(container, setStatus) {
this.container = container;
this.setStatus = setStatus;
}
async render() {
this.container.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<button id="ct-refresh-btn" class="btn">🔄 Aktualisieren</button>
<button id="ct-scrape-btn" class="btn btn-primary" title="Löst den CT-Scrape-Worker sofort aus.">⚡ Scrape jetzt</button>
</div>
<div id="ct-status-text" style="color: var(--text-muted); font-size: 11px;"></div>
</div>
<div class="kpi-panel" id="ct-kpis">
<div class="kpi-card"><div class="kpi-caption">Trades gesamt</div><div class="kpi-value" id="ct-kpi-trades">–</div></div>
<div class="kpi-card"><div class="kpi-caption">Kongressmitglieder</div><div class="kpi-value" id="ct-kpi-members">–</div></div>
<div class="kpi-card"><div class="kpi-caption">Offene Positionen</div><div class="kpi-value" id="ct-kpi-positions">–</div></div>
<div class="kpi-card"><div class="kpi-caption">Investiertes Volumen</div><div class="kpi-value" id="ct-kpi-notional">–</div></div>
</div>
<div class="table-container">
<div style="padding: 10px 14px 6px; font-weight: 600; color: var(--text-muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px;">
Offene Positionen (Modul CT)
</div>
<table class="datagrid">
<thead>
<tr>
<th style="width: 140px;">Symbol</th>
<th style="width: 120px; text-align: right;">Stück</th>
<th style="width: 140px; text-align: right;">Ø-Kurs</th>
<th style="text-align: right;">Wert (USD)</th>
</tr>
</thead>
<tbody id="ct-tbody">
<tr><td colspan="4" style="text-align:center; color: var(--text-muted);">Lade Daten …</td></tr>
</tbody>
</table>
</div>
`;
document.getElementById('ct-refresh-btn').addEventListener('click', () => this.refresh());
document.getElementById('ct-scrape-btn').addEventListener('click', () => this.triggerScrape());
await this.refresh();
}
async refresh() {
this.setStatus('Lade Congress-Trading-Daten …');
try {
const data = await api.get('/api/modules/congresstrading/summary');
if (!data) return;
document.getElementById('ct-kpi-trades').textContent = (data.trades ?? 0).toLocaleString('de-DE');
document.getElementById('ct-kpi-members').textContent = (data.members ?? 0).toLocaleString('de-DE');
const positions = data.positions || [];
document.getElementById('ct-kpi-positions').textContent = positions.length.toLocaleString('de-DE');
const totalNotional = positions.reduce((sum, p) => sum + (Number(p.notional) || 0), 0);
document.getElementById('ct-kpi-notional').textContent =
totalNotional.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' $';
const tbody = document.getElementById('ct-tbody');
if (positions.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" style="text-align:center; color: var(--text-muted);">Keine offenen Positionen im Buch CongressTrading.</td></tr>`;
} else {
tbody.innerHTML = positions.map(p => `
<tr>
<td style="font-weight: 600;">${p.symbol}</td>
<td class="text-right">${Number(p.quantity).toLocaleString('de-DE')}</td>
<td class="text-right">${Number(p.avgPrice).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} $</td>
<td class="text-right" style="font-weight: 500;">${Number(p.notional).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} $</td>
</tr>
`).join('');
}
const now = new Date().toLocaleTimeString('de-DE');
document.getElementById('ct-status-text').textContent = `Stand: ${now} (${positions.length} Positionen)`;
this.setStatus(`Congress Trading aktualisiert um ${now}.`);
} catch (e) {
console.error(e);
this.setStatus(`Fehler: ${e.message}`);
}
}
async triggerScrape() {
const btn = document.getElementById('ct-scrape-btn');
btn.disabled = true;
btn.textContent = '⏳ Starte Scrape …';
this.setStatus('Löse CT-Scrape-Worker aus …');
try {
const res = await api.post('/api/modules/congresstrading/scrape');
if (res && res.success) {
alert(res.message || 'Scrape erfolgreich ausgelöst.');
} else {
alert(`Scrape fehlgeschlagen: ${res?.message || 'Unbekannter Fehler'}`);
}
await this.refresh();
} catch (e) {
alert(`Fehler beim Starten des Scrapes: ${e.message}`);
} finally {
btn.disabled = false;
btn.textContent = '⚡ Scrape jetzt';
}
}
}
@@ -0,0 +1,92 @@
// ─── Dashboard View ───
import { api } from '../api.js';
export class DashboardView {
constructor(container, setStatus) {
this.container = container;
this.setStatus = setStatus;
}
async render() {
this.container.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<button id="dash-refresh-btn" class="btn">🔄 Aktualisieren</button>
<span id="dash-mode-text" style="font-weight: 500; margin-left: 8px;"></span>
</div>
<div id="dash-status-text" style="color: var(--text-muted); font-size: 11px;"></div>
</div>
<div class="kpi-panel" id="dash-kpis">
<div class="kpi-card"><div class="kpi-caption">Offene Positionen</div><div class="kpi-value" id="kpi-open">–</div></div>
<div class="kpi-card"><div class="kpi-caption">Exposure</div><div class="kpi-value" id="kpi-exposure">–</div></div>
<div class="kpi-card"><div class="kpi-caption">Trades gesamt</div><div class="kpi-value" id="kpi-trades">–</div></div>
<div class="kpi-card"><div class="kpi-caption">Worker / Services</div><div class="kpi-value" id="kpi-workers">–</div></div>
</div>
<div class="table-container">
<table class="datagrid">
<thead>
<tr>
<th style="width: 220px;">Modul</th>
<th style="width: 120px;">DB-Präfix</th>
<th>Status / Blocker</th>
</tr>
</thead>
<tbody id="dash-modules-tbody">
<tr><td colspan="3" style="text-align:center; color: var(--text-muted);">Lade Daten …</td></tr>
</tbody>
</table>
</div>
`;
document.getElementById('dash-refresh-btn').addEventListener('click', () => this.refresh());
await this.refresh();
}
async refresh() {
this.setStatus('Lade Dashboard …');
try {
const data = await api.get('/api/dashboard');
if (!data) return;
// Mode
const modeEl = document.getElementById('dash-mode-text');
if (modeEl && data.trading) {
modeEl.textContent = `Trading: ${data.trading.mode} – ${data.trading.tradingEnabled ? 'AKTIV' : 'inaktiv'}`;
modeEl.style.color = data.trading.tradingEnabled ? 'var(--color-success)' : 'var(--text-muted)';
}
// KPIs
if (data.kpis) {
document.getElementById('kpi-open').textContent = data.kpis.openPositions ?? '0';
document.getElementById('kpi-exposure').textContent = Number(data.kpis.totalExposure || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
document.getElementById('kpi-trades').textContent = data.kpis.totalTrades ?? '0';
document.getElementById('kpi-workers').textContent = data.kpis.workerCount ?? '0';
}
// Modules
const tbody = document.getElementById('dash-modules-tbody');
if (tbody && data.modules) {
if (data.modules.length === 0) {
tbody.innerHTML = `<tr><td colspan="3" style="text-align:center; color: var(--text-muted);">Keine Module geladen.</td></tr>`;
} else {
tbody.innerHTML = data.modules.map(m => `
<tr>
<td style="font-weight: 500;">${m.name}</td>
<td><code>${m.dbPrefix}</code></td>
<td>${m.blocker === 'aktivierbar' ? '<span class="badge badge-ok">aktivierbar</span>' : `<span class="badge badge-warn">${m.blocker}</span>`}</td>
</tr>
`).join('');
}
}
const now = new Date().toLocaleTimeString('de-DE');
document.getElementById('dash-status-text').textContent = `Aktualisiert: ${now}`;
this.setStatus(`Dashboard bereit | ${now}`);
} catch (err) {
this.setStatus(`Fehler: ${err.message}`);
}
}
}
@@ -0,0 +1,143 @@
// ─── Logs View (Server-Sent Events) ───
import { api } from '../api.js';
export class LogsView {
constructor(container, setStatus) {
this.container = container;
this.setStatus = setStatus;
this.eventSource = null;
this.filter = '';
this.entries = [];
this.maxLines = 5000;
}
render() {
this.container.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<input type="text" id="log-filter-input" class="input" placeholder="Filter (z. B. CT, Core, Warn) …" style="width: 240px;" />
<button id="log-clear-btn" class="btn">Geleert</button>
<button id="log-copy-btn" class="btn">📋 Kopieren</button>
<label class="checkbox-label" style="margin-left: 8px;">
<input type="checkbox" id="log-autoscroll-chk" checked />
Automatisch scrollen
</label>
</div>
<div id="log-counter" style="color: var(--text-muted); font-size: 11px;">0 Zeilen</div>
</div>
<div class="log-scroller" id="log-scroller-el"></div>
`;
document.getElementById('log-filter-input').addEventListener('input', (e) => {
this.filter = e.target.value.toLowerCase();
this.reapplyFilter();
});
document.getElementById('log-clear-btn').addEventListener('click', () => {
this.entries = [];
document.getElementById('log-scroller-el').innerHTML = '';
this.updateCounter();
this.setStatus('Logs geleert.');
});
document.getElementById('log-copy-btn').addEventListener('click', async () => {
const text = this.entries.map(e => e.formatted || e.message).join('\n');
try {
await navigator.clipboard.writeText(text);
this.setStatus(`${this.entries.length} Zeilen in die Zwischenablage kopiert.`);
} catch (_) {
this.setStatus('Kopieren fehlgeschlagen.');
}
});
this.connectSse();
}
connectSse() {
if (this.eventSource) {
this.eventSource.close();
}
const token = api.token;
const url = `/api/logs/stream${token ? `?token=${encodeURIComponent(token)}` : ''}`;
this.setStatus('Verbinde mit Live-Log-Stream (SSE) …');
this.eventSource = new EventSource(url);
this.eventSource.onopen = () => {
this.setStatus('Live-Log verbunden.');
};
this.eventSource.onmessage = (event) => {
try {
const entry = JSON.parse(event.data);
this.appendEntry(entry);
} catch (_) {}
};
this.eventSource.onerror = () => {
this.setStatus('Live-Log Verbindung unterbrochen – versuche Wiederverbindung …');
};
}
appendEntry(entry) {
this.entries.push(entry);
if (this.entries.length > this.maxLines) {
this.entries.shift();
}
const scroller = document.getElementById('log-scroller-el');
if (!scroller) return;
if (this.matchesFilter(entry)) {
const lineEl = document.createElement('div');
lineEl.className = `log-line ${entry.isError ? 'log-error' : (entry.isWarn ? 'log-warn' : 'log-info')}`;
lineEl.textContent = entry.formatted || `[${entry.timestamp}] [${entry.level}] [${entry.module}] ${entry.message}`;
scroller.appendChild(lineEl);
const autoscroll = document.getElementById('log-autoscroll-chk');
if (autoscroll && autoscroll.checked) {
scroller.scrollTop = scroller.scrollHeight;
}
}
this.updateCounter();
}
matchesFilter(entry) {
if (!this.filter) return true;
const text = (entry.formatted || `${entry.module} ${entry.level} ${entry.message}`).toLowerCase();
return text.includes(this.filter);
}
reapplyFilter() {
const scroller = document.getElementById('log-scroller-el');
if (!scroller) return;
scroller.innerHTML = '';
for (const entry of this.entries) {
if (this.matchesFilter(entry)) {
const lineEl = document.createElement('div');
lineEl.className = `log-line ${entry.isError ? 'log-error' : (entry.isWarn ? 'log-warn' : 'log-info')}`;
lineEl.textContent = entry.formatted || `[${entry.timestamp}] [${entry.level}] [${entry.module}] ${entry.message}`;
scroller.appendChild(lineEl);
}
}
scroller.scrollTop = scroller.scrollHeight;
}
updateCounter() {
const el = document.getElementById('log-counter');
if (el) el.textContent = `${this.entries.length} Zeilen`;
}
destroy() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
}
}
@@ -0,0 +1,205 @@
// ─── Orders View ───
import { api } from '../api.js';
export class OrdersView {
constructor(container, setStatus) {
this.container = container;
this.setStatus = setStatus;
this.orders = [];
this.selected = null;
}
async render() {
this.container.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<button id="ord-refresh-btn" class="btn">🔄 Aktualisieren</button>
<button id="ord-cancel-btn" class="btn btn-danger" disabled>❌ Order stornieren</button>
<button id="ord-cancel-all-btn" class="btn btn-danger" style="margin-left: 12px;">⚠️ Alle stornieren (Paper)</button>
</div>
<div id="ord-status-summary" style="color: var(--text-muted); font-size: 11px;"></div>
</div>
<div class="table-container" style="display: flex; flex-direction: column;">
<!-- Befund / Warnung -->
<div id="ord-unbacked-alert" style="display: none; padding: 10px 14px; background: #fee2e2; border-bottom: 2px solid #ef4444; color: #991b1b;">
<strong>⚠️ GEFÄHRLICHER ZUSTAND:</strong> <span id="ord-unbacked-msg"></span>
</div>
<div style="flex: 1; overflow: auto;">
<div style="padding: 6px 12px; background: #fafafa; font-weight: 600; border-bottom: 1px solid var(--border-color);">
Offene Orders beim Broker
</div>
<table class="datagrid">
<thead>
<tr>
<th style="width: 80px;">Order-ID</th>
<th style="width: 120px;">Symbol</th>
<th style="width: 70px;">Richtung</th>
<th style="width: 80px; text-align: right;">Menge</th>
<th style="width: 80px; text-align: right;">Gefüllt</th>
<th style="width: 80px; text-align: right;">Offen</th>
<th style="width: 70px;">Typ</th>
<th style="width: 90px; text-align: right;">Limit</th>
<th style="width: 120px;">Status</th>
<th>Eigene Kapitalbindung</th>
</tr>
</thead>
<tbody id="ord-tbody">
<tr><td colspan="10" style="text-align:center; color: var(--text-muted);">Lade Orders …</td></tr>
</tbody>
</table>
<div style="padding: 6px 12px; background: #fafafa; font-weight: 600; border-top: 2px solid var(--border-color); border-bottom: 1px solid var(--border-color); margin-top: 12px;">
Eigene Kapitalbindungen (Reservierungen)
</div>
<table class="datagrid">
<thead>
<tr>
<th style="width: 60px;">ID</th>
<th style="width: 100px;">Buch</th>
<th style="width: 70px;">Richtung</th>
<th style="width: 80px; text-align: right;">Menge</th>
<th style="width: 140px;">Kontrakt</th>
<th style="width: 120px; text-align: right;">Betrag</th>
<th style="width: 120px;">Zustand</th>
<th>Broker Order-ID</th>
</tr>
</thead>
<tbody id="res-tbody">
<tr><td colspan="8" style="text-align:center; color: var(--text-muted);">Lade Bindungen …</td></tr>
</tbody>
</table>
</div>
</div>
`;
document.getElementById('ord-refresh-btn').addEventListener('click', () => this.refresh());
document.getElementById('ord-cancel-btn').addEventListener('click', () => this.cancelSelected());
document.getElementById('ord-cancel-all-btn').addEventListener('click', () => this.cancelAll());
await this.refresh();
}
async refresh() {
this.setStatus('Lade Orderbuch und Bindungen …');
try {
const data = await api.get('/api/orders');
if (!data) return;
this.orders = data.orders || [];
const ordTbody = document.getElementById('ord-tbody');
const resTbody = document.getElementById('res-tbody');
// Orders
if (this.orders.length === 0) {
ordTbody.innerHTML = `<tr><td colspan="10" style="text-align:center; color: var(--text-muted);">Keine offenen Orders beim Broker.</td></tr>`;
} else {
ordTbody.innerHTML = this.orders.map((o, idx) => `
<tr data-idx="${idx}" class="${this.selected && this.selected.orderId === o.orderId ? 'selected' : ''}">
<td><b>#${o.orderId}</b></td>
<td><b>${o.symbol}</b></td>
<td>${o.side === 'Buy' ? '<span style="color: green; font-weight:600;">Kauf</span>' : '<span style="color: red; font-weight:600;">Verkauf</span>'}</td>
<td class="text-right">${Number(o.quantity).toLocaleString('de-DE')}</td>
<td class="text-right">${Number(o.filled).toLocaleString('de-DE')}</td>
<td class="text-right"><b>${Number(o.remaining).toLocaleString('de-DE')}</b></td>
<td>${o.orderType}</td>
<td class="text-right">${o.limitPrice !== null ? Number(o.limitPrice).toLocaleString('de-DE', { minimumFractionDigits: 2 }) : '–'}</td>
<td><span class="badge badge-ok">${o.status}</span></td>
<td>${o.isBackedByReservation ? `<span style="color: green;">✔ Gedeckt durch #${o.reservationId} (${o.book})</span>` : '<span class="badge badge-error">UNGEDECKT (keine Bindung!)</span>'}</td>
</tr>
`).join('');
ordTbody.querySelectorAll('tr').forEach(row => {
row.addEventListener('click', () => {
const idx = parseInt(row.getAttribute('data-idx'), 10);
this.selectRow(idx);
});
});
}
// Reservations
const reservations = data.reservations || [];
if (reservations.length === 0) {
resTbody.innerHTML = `<tr><td colspan="8" style="text-align:center; color: var(--text-muted);">Keine offenen Kapitalbindungen.</td></tr>`;
} else {
resTbody.innerHTML = reservations.map(r => `
<tr>
<td>#${r.id}</td>
<td><b>${r.book}</b></td>
<td>${r.side}</td>
<td class="text-right">${Number(r.quantity).toLocaleString('de-DE')}</td>
<td>${r.contractKey}</td>
<td class="text-right">${Number(r.amount).toLocaleString('de-DE', { minimumFractionDigits: 2 })} ${r.currency}</td>
<td><span class="badge badge-warn">${r.state}</span></td>
<td>${r.brokerOrderId ? `<code>#${r.brokerOrderId}</code>` : '<em>(noch keine)</em>'}</td>
</tr>
`).join('');
}
// Unbacked Alert
const alertBox = document.getElementById('ord-unbacked-alert');
const alertMsg = document.getElementById('ord-unbacked-msg');
if (data.unbackedOrders && data.unbackedOrders.length > 0) {
alertBox.style.display = 'block';
alertMsg.textContent = `${data.unbackedOrders.length} Order(s) liegen beim Broker ohne eigene Kapitalbindung! Führt eine davon aus, entsteht eine ungedeckte Position.`;
} else {
alertBox.style.display = 'none';
}
const now = new Date().toLocaleTimeString('de-DE');
document.getElementById('ord-status-summary').textContent =
`Orders: ${this.orders.length}, Bindungen: ${reservations.length} | ${now}`;
this.setStatus(`Orders geladen | ${now}`);
} catch (err) {
this.setStatus(`Fehler beim Laden der Orders: ${err.message}`);
}
}
selectRow(idx) {
const tbody = document.getElementById('ord-tbody');
const cancelBtn = document.getElementById('ord-cancel-btn');
tbody.querySelectorAll('tr').forEach(r => r.classList.remove('selected'));
if (idx >= 0 && idx < this.orders.length) {
this.selected = this.orders[idx];
const selectedTr = tbody.querySelector(`tr[data-idx="${idx}"]`);
if (selectedTr) selectedTr.classList.add('selected');
cancelBtn.disabled = false;
this.setStatus(`Ausgewählt: Order #${this.selected.orderId} (${this.selected.symbol})`);
} else {
this.selected = null;
cancelBtn.disabled = true;
}
}
async cancelSelected() {
if (!this.selected) return;
const id = this.selected.orderId;
if (!confirm(`Soll Order #${id} (${this.selected.symbol}) wirklich storniert werden?`)) return;
this.setStatus(`Storniere Order #${id} …`);
try {
const res = await api.post(`/api/orders/${id}/cancel`);
this.setStatus(res.message || `Order #${id} storniert.`);
await this.refresh();
} catch (err) {
this.setStatus(`Fehler beim Stornieren von #${id}: ${err.message}`);
}
}
async cancelAll() {
if (!confirm('ACHTUNG: Möchtest du wirklich ALLE offenen Orders stornieren? (Nur im Paper-Modus zulässig!)')) return;
this.setStatus('Sammelstorno aller Orders läuft …');
try {
const res = await api.post('/api/orders/cancel-all');
this.setStatus(res.message || 'Sammelstorno ausgeführt.');
await this.refresh();
} catch (err) {
this.setStatus(`Sammelstorno fehlgeschlagen: ${err.message}`);
}
}
}
@@ -0,0 +1,256 @@
// ─── Positions View ───
import { api } from '../api.js';
export class PositionsView {
constructor(container, setStatus) {
this.container = container;
this.setStatus = setStatus;
this.positions = [];
this.selected = null;
}
async render() {
this.container.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<button id="pos-refresh-btn" class="btn">🔄 Aktualisieren</button>
<label class="checkbox-label" style="margin-left: 8px;">
<input type="checkbox" id="pos-broker-chk" checked />
Broker abfragen (Bestandsvergleich)
</label>
</div>
<div id="pos-summary-text" style="color: var(--text-muted); font-size: 11px;"></div>
</div>
<div class="toolbar" style="background: #ffffff; border-bottom: 1px solid var(--border-color); gap: 16px;">
<!-- Sperre -->
<div class="toolbar-group">
<span style="font-weight: 500;">Sperre:</span>
<select id="pos-lock-select">
<option value="Free">frei – keine Sperre</option>
<option value="CloseOnly">nur abbauen (kein Zukauf)</option>
<option value="OpenOnly">nur aufbauen (kein Verkauf)</option>
<option value="Locked">ganz gesperrt (auch Schließen!)</option>
</select>
<button id="pos-apply-lock-btn" class="btn" disabled>Sperre anwenden</button>
</div>
<!-- Umhängen -->
<div class="toolbar-group">
<span style="font-weight: 500;">Umbuchen nach:</span>
<select id="pos-target-book">
<option value="MANUAL">MANUAL</option>
<option value="UNASSIGNED">UNASSIGNED</option>
<option value="HOUSE">HOUSE</option>
<option value="DIAG">DIAG</option>
</select>
<input type="text" id="pos-reassign-reason" class="input" placeholder="Begründung (Pflicht)" style="width: 200px;" />
<button id="pos-reassign-btn" class="btn" disabled>Umbuchen</button>
</div>
</div>
<div class="table-container">
<table class="datagrid">
<thead>
<tr>
<th style="width: 100px;">Buch</th>
<th style="width: 130px;">Herkunft</th>
<th style="width: 140px;">Instrument</th>
<th style="width: 80px; text-align: right;">Menge</th>
<th style="width: 90px; text-align: right;">Einstand</th>
<th style="width: 110px; text-align: right;">Gebunden</th>
<th style="width: 80px; text-align: right;">Broker</th>
<th style="width: 100px; text-align: center;">Abweichung</th>
<th>Sperren & Einschränkungen</th>
</tr>
</thead>
<tbody id="pos-tbody">
<tr><td colspan="9" style="text-align:center; color: var(--text-muted);">Lade Positionen …</td></tr>
</tbody>
</table>
<div id="unknown-section" style="display: none; padding: 12px 14px; background: #fffbeb; border-top: 2px solid #f59e0b;">
<h4 style="color: #b45309; margin-bottom: 6px; font-size: 13px;">⚠️ Bestände beim Broker ohne eigenes Buch (UNKNOWN TO US)</h4>
<table class="datagrid" style="background: white;">
<thead>
<tr>
<th>Symbol</th>
<th>Typ</th>
<th>Währung</th>
<th class="text-right">Menge</th>
<th class="text-right">Einstand</th>
<th>Hinweis</th>
</tr>
</thead>
<tbody id="unknown-tbody"></tbody>
</table>
</div>
</div>
`;
document.getElementById('pos-refresh-btn').addEventListener('click', () => this.refresh());
document.getElementById('pos-apply-lock-btn').addEventListener('click', () => this.applyLock());
document.getElementById('pos-reassign-btn').addEventListener('click', () => this.reassign());
await this.refresh();
}
async refresh() {
const chk = document.getElementById('pos-broker-chk');
const includeBroker = chk ? (chk.checked ? 1 : 0) : 1;
this.setStatus(includeBroker ? 'Lade Positionen und Broker-Bestand …' : 'Lade Positionen …');
try {
const data = await api.get(`/api/positions?broker=${includeBroker}`);
if (!data) return;
this.positions = data.positions || [];
const tbody = document.getElementById('pos-tbody');
if (this.positions.length === 0) {
tbody.innerHTML = `<tr><td colspan="9" style="text-align:center; color: var(--text-muted);">Keine offenen Positionen.</td></tr>`;
} else {
tbody.innerHTML = this.positions.map((p, idx) => `
<tr data-idx="${idx}" class="${this.selected && this.selected.instrument === p.instrument && this.selected.book === p.book ? 'selected' : ''}">
<td><b>${p.book}</b></td>
<td>${p.origin}</td>
<td style="font-weight: 500;">${p.instrument}</td>
<td class="text-right">${Number(p.quantity).toLocaleString('de-DE')}</td>
<td class="text-right">${Number(p.avgPrice).toLocaleString('de-DE', { minimumFractionDigits: 2 })}</td>
<td class="text-right">${Number(p.boundCapital).toLocaleString('de-DE', { maximumFractionDigits: 0 })} ${p.currency}</td>
<td class="text-right">${p.brokerQuantity !== null ? Number(p.brokerQuantity).toLocaleString('de-DE') : '–'}</td>
<td class="text-center">${p.diverges ? '<span class="badge badge-error">Abweichung</span>' : '–'}</td>
<td>${this.formatRestrictions(p)}</td>
</tr>
`).join('');
tbody.querySelectorAll('tr').forEach(row => {
row.addEventListener('click', () => {
const idx = parseInt(row.getAttribute('data-idx'), 10);
this.selectRow(idx);
});
});
}
// Unknown section
const unknownSec = document.getElementById('unknown-section');
const unknownTbody = document.getElementById('unknown-tbody');
if (unknownSec && unknownTbody) {
if (data.unknownToUs && data.unknownToUs.length > 0) {
unknownSec.style.display = 'block';
unknownTbody.innerHTML = data.unknownToUs.map(u => `
<tr>
<td><b>${u.symbol}</b></td>
<td>${u.secType}</td>
<td>${u.currency}</td>
<td class="text-right">${Number(u.quantity).toLocaleString('de-DE')}</td>
<td class="text-right">${Number(u.avgCost).toLocaleString('de-DE', { minimumFractionDigits: 2 })}</td>
<td style="color: #b45309;">Bestand ohne Buch. Beim Abgleich wird er nach UNASSIGNED gebucht.</td>
</tr>
`).join('');
} else {
unknownSec.style.display = 'none';
}
}
const now = new Date().toLocaleTimeString('de-DE');
const diverging = this.positions.filter(p => p.diverges).length;
let summary = `${this.positions.length} Position(en)`;
if (diverging > 0) summary += `, ${diverging} weichen vom Broker ab`;
if (!data.brokerReachable && includeBroker) summary += ' (Broker nicht erreichbar)';
document.getElementById('pos-summary-text').textContent = `${summary} | ${now}`;
this.setStatus(`Positionen geladen | ${now}`);
} catch (err) {
this.setStatus(`Fehler beim Laden der Positionen: ${err.message}`);
}
}
selectRow(idx) {
const tbody = document.getElementById('pos-tbody');
const lockBtn = document.getElementById('pos-apply-lock-btn');
const reassignBtn = document.getElementById('pos-reassign-btn');
tbody.querySelectorAll('tr').forEach(r => r.classList.remove('selected'));
if (idx >= 0 && idx < this.positions.length) {
this.selected = this.positions[idx];
const selectedTr = tbody.querySelector(`tr[data-idx="${idx}"]`);
if (selectedTr) selectedTr.classList.add('selected');
lockBtn.disabled = false;
reassignBtn.disabled = false;
this.setStatus(`Ausgewählt: ${this.selected.instrument} (${this.selected.book})`);
} else {
this.selected = null;
lockBtn.disabled = true;
reassignBtn.disabled = true;
}
}
formatRestrictions(p) {
const parts = [];
if (p.positionLock && p.positionLock !== 'Free') {
parts.push(`<span class="badge badge-warn">${p.positionLock}</span>`);
}
if (p.blocked) {
parts.push(`<span class="badge badge-error">Gesperrt</span>`);
}
if (p.maxQuantity !== null && p.maxQuantity !== undefined) {
parts.push(`max. ${p.maxQuantity} Stk.`);
}
return parts.length > 0 ? parts.join(' ') : '–';
}
async applyLock() {
if (!this.selected) return;
const level = document.getElementById('pos-lock-select').value;
this.setStatus(`Setze Sperre ${level} für ${this.selected.instrument} …`);
try {
const res = await api.post('/api/positions/lock', {
instrument: this.selected.instrument,
book: this.selected.book,
level: level,
note: 'Über Web-UI gesetzt'
});
this.setStatus(res.message || 'Sperre erfolgreich gesetzt.');
await this.refresh();
} catch (err) {
this.setStatus(`Fehler beim Setzen der Sperre: ${err.message}`);
}
}
async reassign() {
if (!this.selected) return;
const targetBook = document.getElementById('pos-target-book').value;
const reasonInput = document.getElementById('pos-reassign-reason');
const reason = reasonInput ? reasonInput.value.trim() : '';
if (!reason) {
alert('Bitte eine Begründung für die Umbuchung eingeben.');
return;
}
this.setStatus(`Bucht ${this.selected.instrument} von ${this.selected.book} nach ${targetBook} um …`);
try {
const res = await api.post('/api/positions/reassign', {
instrument: this.selected.instrument,
fromBook: this.selected.book,
toBook: targetBook,
reason: reason
});
if (res.success) {
if (reasonInput) reasonInput.value = '';
this.setStatus(res.message || 'Erfolgreich umgebucht.');
await this.refresh();
} else {
this.setStatus(`Umbuchung abgelehnt: ${res.message}`);
}
} catch (err) {
this.setStatus(`Fehler: ${err.message}`);
}
}
}
@@ -0,0 +1,149 @@
// ─── Settings View ───
import { api } from '../api.js';
export class SettingsView {
constructor(container, setStatus) {
this.container = container;
this.setStatus = setStatus;
this.sections = [];
this.changedValues = {};
}
async render() {
this.container.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<button id="set-save-btn" class="btn btn-primary">💾 Speichern</button>
<button id="set-reload-btn" class="btn">🔄 Neu laden</button>
</div>
<div id="set-status-msg" style="color: var(--text-muted); font-size: 11px;"></div>
</div>
<div class="settings-scroll" id="settings-form-container">
<div style="text-align: center; padding: 20px; color: var(--text-muted);">Lade Einstellungen …</div>
</div>
`;
document.getElementById('set-save-btn').addEventListener('click', () => this.save());
document.getElementById('set-reload-btn').addEventListener('click', () => this.load());
await this.load();
}
async load() {
this.setStatus('Lade Einstellungen …');
this.changedValues = {};
try {
const data = await api.get('/api/settings');
if (!Array.isArray(data)) return;
this.sections = data;
const formContainer = document.getElementById('settings-form-container');
if (!formContainer) return;
formContainer.innerHTML = data.map((section, sIdx) => `
<div class="settings-section">
<div class="settings-section-header" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'grid' : 'none'">
<span>${section.title}</span>
<span style="font-size: 11px; color: var(--text-muted);">▾</span>
</div>
<div class="settings-section-body" id="section-body-${sIdx}">
${section.fields.map(f => this.renderField(f)).join('')}
</div>
</div>
`).join('');
// Wire input changes
data.forEach(sec => {
sec.fields.forEach(f => {
const inputEl = document.getElementById(`field-${this.escapeId(f.path)}`);
if (inputEl) {
inputEl.addEventListener('change', () => {
if (f.typeName === 'bool') {
this.changedValues[f.path] = inputEl.checked;
} else if (f.typeName === 'int' || f.typeName === 'decimal') {
this.changedValues[f.path] = Number(inputEl.value);
} else {
this.changedValues[f.path] = inputEl.value;
}
document.getElementById('set-status-msg').textContent = `${Object.keys(this.changedValues).length} Änderung(en) ungespeichert`;
});
}
});
});
this.setStatus('Einstellungen geladen.');
document.getElementById('set-status-msg').textContent = 'Bereit.';
} catch (err) {
this.setStatus(`Fehler beim Laden: ${err.message}`);
}
}
escapeId(path) {
return path.replace(/[^a-zA-Z0-9_-]/g, '_');
}
renderField(f) {
const id = `field-${this.escapeId(f.path)}`;
let controlHtml = '';
if (f.typeName === 'bool') {
controlHtml = `<input type="checkbox" id="${id}" ${f.value ? 'checked' : ''} style="cursor: pointer;" />`;
} else if (f.typeName === 'enum' && f.enumValues) {
controlHtml = `
<select id="${id}" style="width: 220px;">
${f.enumValues.map(opt => `<option value="${opt}" ${opt === f.value ? 'selected' : ''}>${opt}</option>`).join('')}
</select>
`;
} else if (f.isPassword) {
controlHtml = `
<input type="password" id="${id}" class="input" placeholder="${f.hasValue ? '(unverändert gespeichert)' : '(kein Wert)'}" style="width: 320px;" />
`;
} else if (f.typeName === 'int' || f.typeName === 'decimal') {
controlHtml = `
<input type="number" id="${id}" class="input" value="${f.value ?? 0}" step="${f.typeName === 'decimal' ? '0.01' : '1'}" style="width: 140px;" />
`;
} else {
controlHtml = `
<input type="text" id="${id}" class="input" value="${this.escapeHtml(f.value ?? '')}" style="width: 360px;" />
`;
}
return `
<div>
<div class="settings-label">${f.displayName}</div>
${f.description ? `<div class="settings-desc">${f.description}</div>` : ''}
</div>
<div>${controlHtml}</div>
`;
}
escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
async save() {
const count = Object.keys(this.changedValues).length;
if (count === 0) {
this.setStatus('Keine Änderungen vorhanden.');
return;
}
this.setStatus(`Speichere ${count} Einstellung(en) …`);
try {
const res = await api.post('/api/settings', { updates: this.changedValues });
this.setStatus(res.message || 'Einstellungen erfolgreich gespeichert.');
document.getElementById('set-status-msg').textContent = 'Gespeichert.';
await this.load();
} catch (err) {
this.setStatus(`Speichern fehlgeschlagen: ${err.message}`);
}
}
}
@@ -0,0 +1,304 @@
// ─── Supervisor View ───
import { api } from '../api.js';
export class SupervisorView {
constructor(container, setStatus) {
this.container = container;
this.setStatus = setStatus;
this.currentSubTab = 'analysis';
this.profiles = [];
this.signals = [];
this.selectedSignal = null;
}
async render() {
this.container.innerHTML = `
<!-- Sub-Nav Tabs -->
<div style="background: #ffffff; border-bottom: 1px solid var(--border-color); display: flex; gap: 4px; padding: 0 12px;">
<button class="nav-tab active" data-subtab="analysis" id="sup-tab-analysis">Analyse</button>
<button class="nav-tab" data-subtab="dossier" id="sup-tab-dossier">Dossier-Browser</button>
<button class="nav-tab" data-subtab="reports" id="sup-tab-reports">Berichte</button>
<button class="nav-tab" data-subtab="info" id="sup-tab-info">Hinweise</button>
</div>
<div id="sup-subcontent" style="flex: 1; display: flex; flex-direction: column; overflow: hidden;">
<!-- Subtab content injected here -->
</div>
`;
const subtabs = this.container.querySelectorAll('[data-subtab]');
subtabs.forEach(tab => {
tab.addEventListener('click', () => {
subtabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
this.currentSubTab = tab.getAttribute('data-subtab');
this.renderSubTab();
});
});
await this.loadProfiles();
await this.renderSubTab();
}
async loadProfiles() {
try {
const data = await api.get('/api/modules/supervisor/profiles');
if (Array.isArray(data)) {
this.profiles = data;
}
} catch (e) {
console.warn('Konnte Supervisor-Profile nicht laden:', e);
}
}
async renderSubTab() {
const sub = document.getElementById('sup-subcontent');
if (!sub) return;
if (this.currentSubTab === 'analysis') {
sub.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<span style="font-weight: 500;">Profil:</span>
<select id="sup-profile-select" class="select" style="min-width: 180px;">
${this.profiles.map(p => `<option value="${p}">${p}</option>`).join('')}
</select>
<button id="sup-ask-btn" class="btn btn-primary">⚡ Fragen</button>
</div>
<div id="sup-ask-status" style="color: var(--text-muted); font-size: 11px;"></div>
</div>
<div style="padding: 10px 14px; background: #ffffff; border-bottom: 1px solid var(--border-color);">
<textarea id="sup-question-input" class="input" style="width: 100%; height: 72px; font-family: var(--font-family); resize: vertical;" placeholder="Frage an den Supervisor eingeben …"></textarea>
</div>
<div id="sup-answer-container" style="flex: 1; background: #14141e; color: #d2d2d2; font-family: var(--font-mono); font-size: 12px; line-height: 1.6; padding: 14px; overflow-y: auto; white-space: pre-wrap; word-break: break-word;">
Wählen Sie ein Profil, geben Sie eine Frage ein und klicken Sie auf 'Fragen'.
</div>
`;
document.getElementById('sup-ask-btn').addEventListener('click', () => this.ask());
document.getElementById('sup-question-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.ask();
}
});
} else if (this.currentSubTab === 'dossier') {
sub.innerHTML = `
<div style="flex: 1; display: flex; overflow: hidden;">
<!-- Left: Signals list -->
<div style="width: 420px; border-right: 1px solid var(--border-color); display: flex; flex-direction: column; background: #ffffff;">
<div class="toolbar" style="border-top: none;">
<button id="sup-load-signals-btn" class="btn">🔄 Signale laden</button>
</div>
<div class="table-container" style="flex: 1;">
<table class="datagrid">
<thead>
<tr>
<th style="width: 100px;">Zeit</th>
<th style="width: 80px;">Symbol</th>
<th style="width: 100px;">Modul</th>
<th>Entscheidung</th>
</tr>
</thead>
<tbody id="sup-signals-tbody">
<tr><td colspan="4" style="text-align:center; color: var(--text-muted);">Signale laden …</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Right: Dossier detail markdown -->
<div style="flex: 1; display: flex; flex-direction: column; background: #14141e;">
<div style="padding: 8px 12px; background: #1e1e2d; border-bottom: 1px solid #2d2d3f; color: #a1a1aa; font-size: 11px; display: flex; justify-content: space-between;">
<span id="sup-dossier-header">Dossier-Details</span>
</div>
<div id="sup-dossier-body" style="flex: 1; overflow-y: auto; padding: 14px; color: #d2d2d2; font-family: var(--font-mono); font-size: 12px; line-height: 1.6; white-space: pre-wrap;">
Signal in der linken Tabelle auswählen.
</div>
</div>
</div>
`;
document.getElementById('sup-load-signals-btn').addEventListener('click', () => this.loadSignals());
await this.loadSignals();
} else if (this.currentSubTab === 'reports') {
sub.innerHTML = `
<div class="toolbar">
<button id="sup-load-reports-btn" class="btn">🔄 Berichte laden</button>
<div id="sup-reports-status" style="color: var(--text-muted); font-size: 11px;"></div>
</div>
<div class="table-container">
<table class="datagrid">
<thead>
<tr>
<th style="width: 140px;">Erstellt</th>
<th style="width: 120px;">Profil</th>
<th style="width: 160px;">Modell</th>
<th>Frage</th>
<th style="width: 60px; text-align: center;">Tools</th>
<th style="width: 110px;">Tokens</th>
</tr>
</thead>
<tbody id="sup-reports-tbody">
<tr><td colspan="6" style="text-align:center; color: var(--text-muted);">Lade Berichte …</td></tr>
</tbody>
</table>
</div>
`;
document.getElementById('sup-load-reports-btn').addEventListener('click', () => this.loadReports());
await this.loadReports();
} else if (this.currentSubTab === 'info') {
sub.innerHTML = `
<div style="padding: 24px; max-width: 800px; color: var(--text-main); line-height: 1.6;">
<h3 style="margin-bottom: 12px; font-size: 16px;">Supervisor Modul-Hinweise</h3>
<div id="sup-info-content">Lade Modul-Informationen …</div>
</div>
`;
await this.loadInfo();
}
}
async ask() {
const qEl = document.getElementById('sup-question-input');
const question = qEl?.value?.trim();
if (!question) {
alert('Bitte eine Frage eingeben.');
return;
}
const pEl = document.getElementById('sup-profile-select');
const profile = pEl?.value;
const btn = document.getElementById('sup-ask-btn');
const ansEl = document.getElementById('sup-answer-container');
const statusEl = document.getElementById('sup-ask-status');
btn.disabled = true;
btn.textContent = '⏳ Analysiere …';
if (statusEl) statusEl.textContent = 'Agent denkt nach …';
ansEl.textContent = 'Agent führt Tool-Aufrufe und Analysen durch … Bitte warten.';
try {
const res = await api.post('/api/modules/supervisor/ask', { question, profile });
if (res && res.success) {
let output = res.answer || '(Keine Antwort erhalten)';
if (res.toolCalls && res.toolCalls.length > 0) {
output += '\n\n── Ausgeführte Tool-Calls ──\n' +
res.toolCalls.map(tc => `• ${tc.tool}: ${JSON.stringify(tc.arguments)}`).join('\n');
}
ansEl.textContent = output;
if (statusEl) statusEl.textContent = `Fertig (${res.toolCallCount || 0} Tools genutzt).`;
} else {
ansEl.textContent = `Fehler: ${res?.message || 'Unbekannter Fehler'}`;
}
} catch (e) {
ansEl.textContent = `Fehler bei der Abfrage: ${e.message}`;
} finally {
btn.disabled = false;
btn.textContent = '⚡ Fragen';
}
}
async loadSignals() {
this.setStatus('Lade Signale …');
try {
const signals = await api.get('/api/modules/supervisor/signals?limit=100');
this.signals = Array.isArray(signals) ? signals : [];
const tbody = document.getElementById('sup-signals-tbody');
if (!tbody) return;
if (this.signals.length === 0) {
tbody.innerHTML = `<tr><td colspan="4" style="text-align:center; color: var(--text-muted);">Keine Signale gefunden.</td></tr>`;
} else {
tbody.innerHTML = this.signals.map((s, idx) => `
<tr data-idx="${idx}" class="${this.selectedSignal && this.selectedSignal.signalId === s.signalId ? 'selected' : ''}">
<td>${s.firstSeen ? s.firstSeen.substring(5, 16) : '–'}</td>
<td style="font-weight: 600;">${s.symbol || '–'}</td>
<td>${s.module || '–'}</td>
<td>${s.lastDecision || '–'}</td>
</tr>
`).join('');
tbody.querySelectorAll('tr').forEach(tr => {
tr.addEventListener('click', () => {
const idx = parseInt(tr.getAttribute('data-idx'), 10);
this.selectSignal(this.signals[idx]);
});
});
}
this.setStatus(`Signale geladen (${this.signals.length}).`);
} catch (e) {
this.setStatus(`Fehler: ${e.message}`);
}
}
async selectSignal(s) {
this.selectedSignal = s;
const headerEl = document.getElementById('sup-dossier-header');
const bodyEl = document.getElementById('sup-dossier-body');
if (!bodyEl) return;
if (headerEl) headerEl.textContent = `Dossier: ${s.symbol} (${s.signalId})`;
bodyEl.textContent = 'Lade Dossier …';
try {
const data = await api.get(`/api/modules/supervisor/dossier/${encodeURIComponent(s.signalId)}`);
if (data && data.markdown) {
bodyEl.textContent = data.markdown;
} else {
bodyEl.textContent = 'Kein Dossier für dieses Signal verfügbar.';
}
} catch (e) {
bodyEl.textContent = `Fehler beim Laden des Dossiers: ${e.message}`;
}
}
async loadReports() {
this.setStatus('Lade Berichte …');
try {
const reports = await api.get('/api/modules/supervisor/reports?limit=100');
const tbody = document.getElementById('sup-reports-tbody');
if (!tbody) return;
if (!Array.isArray(reports) || reports.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center; color: var(--text-muted);">Keine Berichte vorhanden.</td></tr>`;
} else {
tbody.innerHTML = reports.map(r => `
<tr title="${(r.answer || '').replace(/"/g, '&quot;')}">
<td>${r.createdAt}</td>
<td><b>${r.profile}</b></td>
<td style="font-family: var(--font-mono); font-size: 11px;">${r.model}</td>
<td>${r.question}</td>
<td class="text-center">${r.toolCallCount}</td>
<td style="font-size: 11px;">${r.tokens}</td>
</tr>
`).join('');
}
this.setStatus(`Berichte geladen (${reports ? reports.length : 0}).`);
} catch (e) {
this.setStatus(`Fehler: ${e.message}`);
}
}
async loadInfo() {
try {
const info = await api.get('/api/modules/supervisor/info');
const el = document.getElementById('sup-info-content');
if (!el || !info) return;
el.innerHTML = `
<div style="margin-bottom: 16px; padding: 12px; border-radius: 4px; border: 1px solid ${info.openRouterKeyConfigured ? '#86efac' : '#fde68a'}; background: ${info.openRouterKeyConfigured ? '#f0fdf4' : '#fffbeb'}; color: ${info.openRouterKeyConfigured ? '#15803d' : '#b45309'};">
<b>OpenRouter API-Key:</b> ${info.openRouterKeyConfigured ? '✅ Konfiguriert' : '⚠️ Nicht gesetzt (in settings.json oder OPENROUTER_API_KEY hinterlegen)'}
</div>
<p style="margin-bottom: 8px;"><b>Zeitzone:</b> ${info.timeZone || '–'}</p>
<p style="margin-bottom: 8px;"><b>Beschreibung:</b> ${info.description || '–'}</p>
`;
} catch (e) {
console.error(e);
}
}
}
@@ -0,0 +1,125 @@
// ─── Workers / Services View ───
import { api } from '../api.js';
export class WorkersView {
constructor(container, setStatus) {
this.container = container;
this.setStatus = setStatus;
this.selectedWorker = null;
this.workers = [];
}
async render() {
this.container.innerHTML = `
<div class="toolbar">
<div class="toolbar-group">
<button id="workers-trigger-btn" class="btn btn-primary" disabled>⚡ Jetzt ausführen</button>
<button id="workers-refresh-btn" class="btn">🔄 Aktualisieren</button>
</div>
<div id="workers-status-text" style="color: var(--text-muted); font-size: 11px;"></div>
</div>
<div class="table-container">
<table class="datagrid">
<thead>
<tr>
<th style="width: 50px; text-align: center;">Aktiv</th>
<th style="width: 80px;">Typ</th>
<th style="width: 110px;">Modul</th>
<th style="width: 220px;">Worker</th>
<th style="width: 150px;">Letzter Lauf</th>
<th style="width: 150px;">Nächster Lauf</th>
<th style="width: 90px;">Intervall</th>
<th>Info</th>
</tr>
</thead>
<tbody id="workers-tbody">
<tr><td colspan="8" style="text-align:center; color: var(--text-muted);">Lade Worker …</td></tr>
</tbody>
</table>
</div>
`;
document.getElementById('workers-refresh-btn').addEventListener('click', () => this.refresh());
document.getElementById('workers-trigger-btn').addEventListener('click', () => this.triggerSelected());
await this.refresh();
}
async refresh() {
this.setStatus('Lade Worker-Liste …');
try {
const data = await api.get('/api/workers');
if (!Array.isArray(data)) return;
this.workers = data;
const tbody = document.getElementById('workers-tbody');
const triggerBtn = document.getElementById('workers-trigger-btn');
if (data.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" style="text-align:center; color: var(--text-muted);">Keine Worker registriert.</td></tr>`;
return;
}
tbody.innerHTML = data.map((w, idx) => `
<tr data-idx="${idx}" class="${this.selectedWorker && this.selectedWorker.workerName === w.workerName ? 'selected' : ''}">
<td style="text-align: center;">${w.active ? '✔' : '–'}</td>
<td><span class="badge ${w.type === 'Service' ? 'badge-ok' : 'badge-warn'}">${w.type}</span></td>
<td><b>${w.module}</b></td>
<td style="font-weight: 500;">${w.workerName}</td>
<td>${w.lastRuntime || '–'}</td>
<td>${w.nextRuntime || '–'}</td>
<td>${w.runEvery || '–'}</td>
<td title="${w.info || ''}">${w.info || '–'}</td>
</tr>
`).join('');
// Wire row click
tbody.querySelectorAll('tr').forEach(row => {
row.addEventListener('click', () => {
const idx = parseInt(row.getAttribute('data-idx'), 10);
this.selectRow(idx);
});
});
const now = new Date().toLocaleTimeString('de-DE');
document.getElementById('workers-status-text').textContent = `${data.length} Worker/Services registriert | ${now}`;
this.setStatus(`Worker-Liste aktualisiert | ${now}`);
} catch (err) {
this.setStatus(`Fehler: ${err.message}`);
}
}
selectRow(idx) {
const tbody = document.getElementById('workers-tbody');
const triggerBtn = document.getElementById('workers-trigger-btn');
tbody.querySelectorAll('tr').forEach(r => r.classList.remove('selected'));
if (idx >= 0 && idx < this.workers.length) {
this.selectedWorker = this.workers[idx];
const selectedTr = tbody.querySelector(`tr[data-idx="${idx}"]`);
if (selectedTr) selectedTr.classList.add('selected');
triggerBtn.disabled = false;
this.setStatus(`Ausgewählt: ${this.selectedWorker.workerName}`);
} else {
this.selectedWorker = null;
triggerBtn.disabled = true;
}
}
async triggerSelected() {
if (!this.selectedWorker) return;
const name = this.selectedWorker.workerName;
this.setStatus(`${name} wird ausgelöst …`);
try {
const res = await api.post(`/api/workers/${encodeURIComponent(name)}/trigger`);
this.setStatus(res.message || `${name} erfolgreich ausgelöst.`);
await this.refresh();
} catch (err) {
this.setStatus(`Fehler beim Auslösen von ${name}: ${err.message}`);
}
}
}
@@ -28,6 +28,7 @@
<ProjectReference Include="..\..\src\IBKRTrader.Modules.Accounting\IBKRTrader.Modules.Accounting.csproj" /> <ProjectReference Include="..\..\src\IBKRTrader.Modules.Accounting\IBKRTrader.Modules.Accounting.csproj" />
<ProjectReference Include="..\..\src\IBKRTrader.Modules.Supervisor\IBKRTrader.Modules.Supervisor.csproj" /> <ProjectReference Include="..\..\src\IBKRTrader.Modules.Supervisor\IBKRTrader.Modules.Supervisor.csproj" />
<ProjectReference Include="..\..\src\IBKRTrader.Modules.DebugTrading\IBKRTrader.Modules.DebugTrading.csproj" /> <ProjectReference Include="..\..\src\IBKRTrader.Modules.DebugTrading\IBKRTrader.Modules.DebugTrading.csproj" />
<ProjectReference Include="..\..\src\IBKRTrader.Hosting\IBKRTrader.Hosting.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -0,0 +1,218 @@
using FluentAssertions;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Orders;
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Persistence.Entities;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Trading;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
namespace IBKRTrader.Tests.Orders;
[Trait("cat", "unit")]
public class OrderManagerTests
{
private sealed class InMemoryFactory : IDbContextFactory<CoreDbContext>
{
private readonly DbContextOptions<CoreDbContext> _options;
public InMemoryFactory(DbContextOptions<CoreDbContext> options) => _options = options;
public CoreDbContext CreateDbContext() => new(_options);
}
private static (OrderManager Sut, IBrokerOrderBook OrderBook, IDbContextFactory<CoreDbContext> Dbf, SettingsService Settings) CreateSut()
{
var options = new DbContextOptionsBuilder<CoreDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var factory = new InMemoryFactory(options);
var orderBook = Substitute.For<IBrokerOrderBook>();
var settings = new SettingsService();
var logger = new LoggingService();
var sut = new OrderManager(orderBook, factory, settings, logger);
return (sut, orderBook, factory, settings);
}
[Fact]
public async Task GetOrderOverviewAsync_IdentifiesBackedAndUnbackedOrders()
{
var (sut, orderBook, dbf, _) = CreateSut();
// 1. Broker-Orders vorbereiten
var bo1 = new BrokerOrder(
OrderId: 101, PermId: 1001, Instrument: InstrumentRef.ForStock("AAPL"),
Side: TradeSide.Buy, Quantity: 10, Filled: 0, Remaining: 10,
OrderType: "LMT", LimitPrice: 150m, Status: "Submitted", Account: "U123");
var bo2 = new BrokerOrder(
OrderId: 102, PermId: 1002, Instrument: InstrumentRef.ForStock("MSFT"),
Side: TradeSide.Buy, Quantity: 5, Filled: 0, Remaining: 5,
OrderType: "LMT", LimitPrice: 400m, Status: "Submitted", Account: "U123");
orderBook.GetOpenOrdersAsync(Arg.Any<CancellationToken>())
.Returns([bo1, bo2]);
// 2. Eigene Kapitalbindung nur für bo1 anlegen
await using (var db = dbf.CreateDbContext())
{
db.Reservations.Add(new CoreReservation
{
Id = 1,
SignalId = "sig-1",
Book = "CT",
Side = "BUY",
Quantity = 10,
ContractKey = "AAPL",
Amount = 1500m,
Currency = "USD",
State = ReservationState.Submitted,
BrokerOrderId = "101",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
}
// Act
var overview = await sut.GetOrderOverviewAsync();
// Assert
overview.BrokerReachable.Should().BeTrue();
overview.Orders.Should().HaveCount(2);
var managed1 = overview.Orders.First(o => o.BrokerOrder.OrderId == 101);
managed1.IsBackedByReservation.Should().BeTrue();
managed1.ReservationId.Should().Be(1);
managed1.Book.Should().Be("CT");
var managed2 = overview.Orders.First(o => o.BrokerOrder.OrderId == 102);
managed2.IsBackedByReservation.Should().BeFalse();
managed2.ReservationId.Should().BeNull();
overview.UnbackedOrders.Should().HaveCount(1);
overview.UnbackedOrders.Single().BrokerOrder.OrderId.Should().Be(102);
}
[Fact]
public async Task CancelOrderAsync_DelegatesToBrokerOrderBook()
{
var (sut, orderBook, _, _) = CreateSut();
orderBook.CancelOrderAsync(101, Arg.Any<CancellationToken>()).Returns(true);
var result = await sut.CancelOrderAsync(101);
result.Success.Should().BeTrue();
result.Message.Should().Contain("101");
await orderBook.Received(1).CancelOrderAsync(101, Arg.Any<CancellationToken>());
}
[Fact]
public async Task CancelAllOrdersAsync_RejectsWhenNotPaperMode()
{
var (sut, orderBook, _, settings) = CreateSut();
settings.Settings.Trading.Mode = "Live";
var result = await sut.CancelAllOrdersAsync();
result.Success.Should().BeFalse();
result.Message.Should().Contain("Handelsmodus");
await orderBook.DidNotReceive().GetOpenOrdersAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task CancelAllOrdersAsync_CancelsAllOrdersInPaperMode()
{
var (sut, orderBook, _, settings) = CreateSut();
settings.Settings.Trading.Mode = "Paper";
var bo1 = new BrokerOrder(
OrderId: 201, PermId: 2001, Instrument: InstrumentRef.ForStock("AAPL"),
Side: TradeSide.Buy, Quantity: 10, Filled: 0, Remaining: 10,
OrderType: "LMT", LimitPrice: 150m, Status: "Submitted", Account: "U123");
var bo2 = new BrokerOrder(
OrderId: 202, PermId: 2002, Instrument: InstrumentRef.ForStock("MSFT"),
Side: TradeSide.Buy, Quantity: 5, Filled: 0, Remaining: 5,
OrderType: "LMT", LimitPrice: 400m, Status: "Submitted", Account: "U123");
orderBook.GetOpenOrdersAsync(Arg.Any<CancellationToken>()).Returns([bo1, bo2]);
orderBook.CancelOrderAsync(Arg.Any<int>(), Arg.Any<CancellationToken>()).Returns(true);
var result = await sut.CancelAllOrdersAsync();
result.Success.Should().BeTrue();
result.Cancelled.Should().Be(2);
result.Total.Should().Be(2);
await orderBook.Received(1).CancelOrderAsync(201, Arg.Any<CancellationToken>());
await orderBook.Received(1).CancelOrderAsync(202, Arg.Any<CancellationToken>());
}
[Fact]
public async Task GetOrderOverviewAsync_WhenReservationHasNoBrokerOrderId_TreatedAsUnbacked()
{
var (sut, orderBook, dbf, _) = CreateSut();
var bo = new BrokerOrder(
OrderId: 301, PermId: 3001, Instrument: InstrumentRef.ForStock("NVDA"),
Side: TradeSide.Buy, Quantity: 20, Filled: 0, Remaining: 20,
OrderType: "LMT", LimitPrice: 120m, Status: "Submitted", Account: "U123");
orderBook.GetOpenOrdersAsync(Arg.Any<CancellationToken>()).Returns([bo]);
await using (var db = dbf.CreateDbContext())
{
db.Reservations.Add(new CoreReservation
{
Id = 10,
SignalId = "sig-nvda",
Book = "CT",
Side = "BUY",
Quantity = 20,
ContractKey = "NVDA",
Amount = 2400m,
Currency = "USD",
State = ReservationState.Reserved,
BrokerOrderId = null, // noch keine Order-ID zugewiesen
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
}
var overview = await sut.GetOrderOverviewAsync();
overview.BrokerReachable.Should().BeTrue();
overview.Orders.Should().HaveCount(1);
var mo = overview.Orders.Single();
mo.IsBackedByReservation.Should().BeFalse();
overview.UnbackedOrders.Should().HaveCount(1);
}
[Fact]
public async Task GetOrderOverviewAsync_HandlesBrokerException_Gracefully()
{
var (sut, orderBook, _, _) = CreateSut();
orderBook.GetOpenOrdersAsync(Arg.Any<CancellationToken>())
.Returns<IReadOnlyList<BrokerOrder>>(_ => throw new InvalidOperationException("Broker offline"));
var overview = await sut.GetOrderOverviewAsync();
overview.BrokerReachable.Should().BeFalse();
overview.Orders.Should().BeEmpty();
}
[Fact]
public async Task CancelOrderAsync_WhenBrokerReturnsFalse_ReportsFailure()
{
var (sut, orderBook, _, _) = CreateSut();
orderBook.CancelOrderAsync(999, Arg.Any<CancellationToken>()).Returns(false);
var result = await sut.CancelOrderAsync(999);
result.Success.Should().BeFalse();
result.Message.Should().Contain("NICHT bestätigt");
}
}
@@ -0,0 +1,99 @@
using FluentAssertions;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Tests.Settings;
[Trait("cat", "unit")]
public class SettingsSchemaTests
{
[Fact]
public void Build_ExtractsSectionsAndFieldsFromAppSettings()
{
var settings = new AppSettings();
var sections = SettingsSchema.Build(settings);
sections.Should().NotBeEmpty();
sections.SelectMany(s => s.Fields).Should().NotBeEmpty();
// Webserver-Abschnitt prüfen
var ws = sections.FirstOrDefault(s => s.Fields.Any(f => f.Path == "Webserver.Port"));
ws.Should().NotBeNull();
}
[Fact]
public void ToDto_MasksPasswordsAndTokens()
{
var settings = new AppSettings();
settings.Webserver.AuthToken = "super-secret-1234";
settings.Database.Password = "db-pass-secret";
var dtos = SettingsSchema.ToDto(settings);
var allFields = dtos.SelectMany(s => s.Fields).ToList();
// AuthToken prüfen
var tokenField = allFields.FirstOrDefault(f => f.Path == "Webserver.AuthToken");
tokenField.Should().NotBeNull();
tokenField!.IsPassword.Should().BeTrue();
tokenField.HasValue.Should().BeTrue();
tokenField.Value.Should().BeNull("Passwörter und Tokens dürfen niemals im Klartext im DTO stehen!");
// DB Password prüfen
var passField = allFields.FirstOrDefault(f => f.Path == "Database.Password");
passField.Should().NotBeNull();
passField!.IsPassword.Should().BeTrue();
passField.Value.Should().BeNull();
}
[Fact]
public void TrySetValue_UpdatesValueByPath()
{
var settings = new AppSettings();
settings.Webserver.Port = 5001;
var success = SettingsSchema.TrySetValue(settings, "Webserver.Port", 6002);
success.Should().BeTrue();
settings.Webserver.Port.Should().Be(6002);
}
[Fact]
public void TrySetValue_PreservesPasswordWhenEmptyPassed()
{
var settings = new AppSettings();
settings.Webserver.AuthToken = "keep-this-token";
var success = SettingsSchema.TrySetValue(settings, "Webserver.AuthToken", "");
success.Should().BeTrue();
settings.Webserver.AuthToken.Should().Be("keep-this-token");
}
[Fact]
public void TrySetValue_ReturnsFalseForUnknownPath()
{
var settings = new AppSettings();
var success = SettingsSchema.TrySetValue(settings, "NonExistent.Field", "val");
success.Should().BeFalse();
}
[Fact]
public void TrySetValue_ParsesStringRepresentationOfTypes()
{
var settings = new AppSettings();
// int als string
SettingsSchema.TrySetValue(settings, "Webserver.Port", "7001").Should().BeTrue();
settings.Webserver.Port.Should().Be(7001);
// bool als string
SettingsSchema.TrySetValue(settings, "Webserver.Enabled", "true").Should().BeTrue();
settings.Webserver.Enabled.Should().BeTrue();
// bool als false
SettingsSchema.TrySetValue(settings, "Webserver.Enabled", "false").Should().BeTrue();
settings.Webserver.Enabled.Should().BeFalse();
}
}
@@ -0,0 +1,111 @@
using FluentAssertions;
using IBKRTrader.Core.Settings;
using IBKRTrader.Hosting.Web;
namespace IBKRTrader.Tests.Web;
[Trait("cat", "unit")]
public class AuthServiceTests
{
[Fact]
public void ValidateToken_ReturnsTrueForCorrectToken()
{
var settings = new SettingsService();
settings.Settings.Webserver.AuthToken = "secret-token-123";
var sut = new AuthService(settings);
sut.IsConfigured.Should().BeTrue();
sut.ValidateToken("secret-token-123").Should().BeTrue();
sut.ValidateToken("wrong-token").Should().BeFalse();
sut.ValidateToken("").Should().BeFalse();
}
[Fact]
public void ValidateToken_ReturnsFalseWhenNotConfigured()
{
var settings = new SettingsService();
settings.Settings.Webserver.AuthToken = "";
var sut = new AuthService(settings);
sut.IsConfigured.Should().BeFalse();
sut.ValidateToken("any-token").Should().BeFalse();
}
[Fact]
public void SessionToken_RoundTrip_ValidatesSuccessfully()
{
var settings = new SettingsService();
settings.Settings.Webserver.AuthToken = "my-secret-key";
var sut = new AuthService(settings);
var sessionToken = sut.CreateSessionToken(TimeSpan.FromHours(1));
sessionToken.Should().NotBeNullOrWhiteSpace();
sut.ValidateSessionToken(sessionToken).Should().BeTrue();
}
[Fact]
public void SessionToken_TamperedToken_FailsValidation()
{
var settings = new SettingsService();
settings.Settings.Webserver.AuthToken = "my-secret-key";
var sut = new AuthService(settings);
var sessionToken = sut.CreateSessionToken(TimeSpan.FromHours(1));
var tampered = sessionToken + "tamper";
sut.ValidateSessionToken(tampered).Should().BeFalse();
}
[Fact]
public void SessionToken_ExpiredToken_FailsValidation()
{
var settings = new SettingsService();
settings.Settings.Webserver.AuthToken = "my-secret-key";
var sut = new AuthService(settings);
// Abgelaufenes Token (minus 1 Sekunde)
var sessionToken = sut.CreateSessionToken(TimeSpan.FromSeconds(-10));
sut.ValidateSessionToken(sessionToken).Should().BeFalse();
}
[Fact]
public void IsAuthenticated_RecognizesBearerHeader()
{
var settings = new SettingsService();
settings.Settings.Webserver.AuthToken = "my-secret-key";
var sut = new AuthService(settings);
var ctx = new Microsoft.AspNetCore.Http.DefaultHttpContext();
ctx.Request.Headers.Authorization = "Bearer my-secret-key";
sut.IsAuthenticated(ctx).Should().BeTrue();
}
[Fact]
public void IsAuthenticated_RecognizesQueryToken()
{
var settings = new SettingsService();
settings.Settings.Webserver.AuthToken = "my-secret-key";
var sut = new AuthService(settings);
var ctx = new Microsoft.AspNetCore.Http.DefaultHttpContext();
ctx.Request.QueryString = new Microsoft.AspNetCore.Http.QueryString("?token=my-secret-key");
sut.IsAuthenticated(ctx).Should().BeTrue();
}
[Fact]
public void IsAuthenticated_RejectsUnauthenticatedRequest()
{
var settings = new SettingsService();
settings.Settings.Webserver.AuthToken = "my-secret-key";
var sut = new AuthService(settings);
var ctx = new Microsoft.AspNetCore.Http.DefaultHttpContext();
sut.IsAuthenticated(ctx).Should().BeFalse();
}
}
@@ -0,0 +1,39 @@
using FluentAssertions;
using IBKRTrader.Core.Logging;
using IBKRTrader.Hosting.Web;
namespace IBKRTrader.Tests.Web;
[Trait("cat", "unit")]
public class LogStreamServiceTests
{
[Fact]
public void GetRecent_CapturesLogEntries_AndCapsAtRingBufferSize()
{
var logger = new LoggingService();
using var sut = new LogStreamService(logger);
for (int i = 0; i < 150; i++)
{
logger.Info("TestModule", $"Message {i}");
}
var recent = sut.GetRecent();
recent.Count.Should().Be(100);
}
[Fact]
public void Dispose_UnsubscribesFromLogger()
{
var logger = new LoggingService();
var sut = new LogStreamService(logger);
logger.Info("TestModule", "Before dispose");
sut.GetRecent().Count.Should().Be(1);
sut.Dispose();
logger.Info("TestModule", "After dispose");
sut.GetRecent().Count.Should().Be(1);
}
}
@@ -0,0 +1,60 @@
using FluentAssertions;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Settings;
using IBKRTrader.Core.Workers;
using IBKRTrader.Hosting.Web;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
namespace IBKRTrader.Tests.Web;
[Trait("cat", "unit")]
public class WebserverWorkerTests
{
private sealed class InMemoryFactory : IDbContextFactory<CoreDbContext>
{
private readonly DbContextOptions<CoreDbContext> _options;
public InMemoryFactory(DbContextOptions<CoreDbContext> options) => _options = options;
public CoreDbContext CreateDbContext() => new(_options);
}
private static (WebserverWorker Sut, SettingsService Settings) CreateSut(bool enabled = true, int port = 5001)
{
var options = new DbContextOptionsBuilder<CoreDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var dbf = new InMemoryFactory(options);
var settings = new SettingsService();
settings.Settings.Webserver.Enabled = enabled;
settings.Settings.Webserver.Port = port;
var logger = new LoggingService();
var sp = Substitute.For<IServiceProvider>();
var sut = new WebserverWorker(logger, dbf, settings, sp);
return (sut, settings);
}
[Fact]
public void Worker_Properties_AreConfiguredCorrectly()
{
var (sut, _) = CreateSut(enabled: true, port: 5001);
sut.Name.Should().Be("WebserverService");
sut.Module.Should().Be("Core");
sut.Type.Should().Be(WorkerType.Service);
sut.Info.Active.Should().BeTrue();
sut.Info.RunEvery.Should().Be("Service");
sut.Info.Info.Should().Be("Port 5001");
}
[Fact]
public void Worker_WhenDisabled_ReportsInactive()
{
var (sut, _) = CreateSut(enabled: false, port: 8080);
sut.Info.Active.Should().BeFalse();
sut.Info.Info.Should().Be("Deaktiviert");
}
}