feat: Web-UI als zweiter Ausgabeweg neben Avalonia-Shell (#490)
- 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:
@@ -57,3 +57,8 @@ artifacts/
|
||||
|
||||
## Deploymentcenter-Token aus der Selbst-Provisionierung (Werkbank + Laufzeit)
|
||||
.dc-tokens.local.json
|
||||
|
||||
## Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
|
||||
+32
-21
@@ -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
|
||||
`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
|
||||
|
||||
1. **[X] #388 — WorkerBase: Intervall ueber 24,85 Tage toetet den Worker still und reisst beim Herunterfahren den Prozess mit**
|
||||
2. **[X] #390 — Fehlgeschlagener DB-Dump gilt als erfolgreicher Backup-Lauf**
|
||||
3. **[X] #389 — BackupWorker sichert eine andere Datenbank als die, mit der die Anwendung arbeitet**
|
||||
4. **[X] #391 — --check meldet "Pruefdurchlauf OK", ohne die Datenbank je zu erreichen**
|
||||
1. **[X] #396 — Die Shell reagiert unter Linux nicht auf SIGTERM - kein geordnetes Herunterfahren ausserhalb des Fensters**
|
||||
2. **[X] #475 — Order-Nachweis auf der Test-VM durch die Lizenzsperre blockiert: kein Schluessel hinterlegt**
|
||||
3. **[B] #289 — DB-Passwort der produktiven MariaDB rotieren**
|
||||
4. **[B] #290 — Gitea-Zugangstoken aus der Remote-URL nehmen**
|
||||
|
||||
### X — Kritische Bugs
|
||||
|
||||
| | Vorhaben | Stand | Ziel |
|
||||
|---|---|---|---|
|
||||
| #388 | WorkerBase: Intervall ueber 24,85 Tage toetet den Worker still und reisst beim Herunterfahren den Prozess mit | 📋 Offen | — |
|
||||
| #390 | Fehlgeschlagener DB-Dump gilt als erfolgreicher Backup-Lauf | 📋 Offen | — |
|
||||
| #389 | BackupWorker sichert eine andere Datenbank als die, mit der die Anwendung arbeitet | 📋 Offen | — |
|
||||
| #391 | --check meldet "Pruefdurchlauf OK", ohne die Datenbank je zu erreichen | 📋 Offen | — |
|
||||
| #394 | CapitolTrades-Scraper behandelt 429 nicht - beide CT-Worker fallen bei jedem Lauf aus | 📋 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 | ✅ Erledigt | `1.0.0+b618efa` |
|
||||
| #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 | ✅ Erledigt | `1.0.0+b618efa` |
|
||||
| #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 | — |
|
||||
| #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
|
||||
|
||||
@@ -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 | — |
|
||||
| #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)
|
||||
|
||||
| | 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 | — |
|
||||
| #295 | IBKR-Zugangsdaten mit EncryptedStringConverter ablegen | 📋 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
|
||||
|
||||
| | Vorhaben | Stand | Ziel |
|
||||
|---|---|---|---|
|
||||
| #298 | InstrumentKind/OptionSpec, IbkrMapping.Option, Positionen mit Multiplikator | 📋 Offen | — |
|
||||
| #299 | Optionskette und Greeks im Core (reqSecDefOptParams, tickOptionComputation) | 📋 Offen | — |
|
||||
| #300 | Positionsabgleich bei Zuteilung/Verfall und RiskService um sell-to-open erweitern | 📋 Offen | — |
|
||||
| #298 | InstrumentKind/OptionSpec, IbkrMapping.Option, Positionen mit Multiplikator | ✅ Erledigt | `1.0.0` |
|
||||
| #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 | ✅ Erledigt | `1.0.0+b618efa` |
|
||||
| #301 | IV-Rank als Core-Baustein | 📋 Offen | — |
|
||||
|
||||
### K — Kapital- und Buchmodell
|
||||
|
||||
| | Vorhaben | Stand | Ziel |
|
||||
|---|---|---|---|
|
||||
| #302 | Buecher und Eigentumsregeln | 📋 Offen | — |
|
||||
| #303 | Ein Pool, Obergrenzen | 📋 Offen | — |
|
||||
| #304 | Margin-Sperre in vier Schichten | 📋 Offen | — |
|
||||
| #305 | Reservierungen ueber den Order-Lebenszyklus inkl. Crash-Recovery | 📋 Offen | — |
|
||||
| #302 | Buecher und Eigentumsregeln | ✅ Erledigt | `1.0.0+b618efa` |
|
||||
| #474 | Eigentumsregeln 3 und 4: Modul kann sich als anderes Buch ausgeben, IBrokerClient ist per DI erreichbar | ✅ Erledigt | `1.0.0+b618efa` |
|
||||
| #303 | Ein Pool, Obergrenzen | ✅ Erledigt | `1.0.0` |
|
||||
| #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 | — |
|
||||
| #307 | Benachrichtigungen ueber Outbox und Sink-Abstraktion | 📋 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 |
|
||||
|---|---|---|---|
|
||||
| #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 | — |
|
||||
| #312 | OptionsWheel: Earnings-Sperre ueber den IV-Behelf | 📋 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 | — |
|
||||
| #315 | Tokens fuer ibkrtrader vervollstaendigen | 📋 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 | — |
|
||||
| #318 | Deploymentcenter-Baustein in die Projektdatei aufnehmen | ✅ Erledigt | — |
|
||||
| #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 | — |
|
||||
| #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 | — |
|
||||
| #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 | — |
|
||||
| #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 | — |
|
||||
| #337 | InvariantGlobalization=true fuer ein schlankes Linux-Image | ❌ Verworfen | — |
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -151,17 +151,30 @@ public class LoggingSettings
|
||||
[TypeConverter(typeof(ExpandableObjectConverter))]
|
||||
public class WebserverSettings
|
||||
{
|
||||
[Category("Webserver")]
|
||||
[DisplayName("Aktiviert")]
|
||||
[Description("Webserver und REST-API beim Start aktivieren")]
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
[Category("Webserver")]
|
||||
[DisplayName("Port")]
|
||||
[Description("Port des internen Webservers (Web-UI)")]
|
||||
public int Port { get; set; } = 5001;
|
||||
|
||||
[Category("Webserver")]
|
||||
[DisplayName("Aktiviert")]
|
||||
[Description("Webserver und REST-API beim Start aktivieren")]
|
||||
public bool Enabled { get; set; } = false;
|
||||
[DisplayName("Netzwerkzugriff erlauben")]
|
||||
[Description("Wenn aktiviert, lauscht der Server auf allen Schnittstellen (0.0.0.0), sonst nur lokal (127.0.0.1)")]
|
||||
public bool AllowRemoteAccess { get; set; } = true;
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,27 @@
|
||||
using System.Globalization;
|
||||
using IBKRTrader.Core.Persistence.Ef;
|
||||
using IBKRTrader.Core.Persistence.Entities;
|
||||
using IBKRTrader.Core.Settings;
|
||||
using IBKRTrader.Core.Trading;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using IBKRTrader.Core.Orders;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace IBKRTrader.Daemon;
|
||||
|
||||
/// <summary>
|
||||
/// Der Blick ins Orderbuch des Brokers – und der Abgleich mit dem, was wir selbst gebucht haben
|
||||
/// (#488).
|
||||
///
|
||||
/// <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>
|
||||
/// (#488). Nutzt den Core-Dienst <see cref="OrderManager"/>.
|
||||
/// </summary>
|
||||
internal static class OrderBookTool
|
||||
{
|
||||
public static async Task<int> ListAsync(IServiceProvider services)
|
||||
{
|
||||
var inv = CultureInfo.InvariantCulture;
|
||||
var book = services.GetRequiredService<IBrokerOrderBook>();
|
||||
var dbf = services.GetRequiredService<IDbContextFactory<CoreDbContext>>();
|
||||
var inv = CultureInfo.InvariantCulture;
|
||||
var manager = services.GetRequiredService<OrderManager>();
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(90));
|
||||
|
||||
var orders = await book.GetOpenOrdersAsync(cts.Token).ConfigureAwait(false);
|
||||
var overview = await manager.GetOrderOverviewAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("=== Offene Orders beim Broker ===");
|
||||
|
||||
if (orders.Count == 0)
|
||||
if (overview.Orders.Count == 0)
|
||||
{
|
||||
Console.WriteLine("Keine gemeldet.");
|
||||
Console.WriteLine("ACHTUNG: Das heißt nur dann „keine offenen Orders\", wenn oben eine");
|
||||
@@ -45,49 +29,29 @@ internal static class OrderBookTool
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var o in orders)
|
||||
Console.WriteLine($" {o}");
|
||||
foreach (var o in overview.Orders)
|
||||
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("=== Eigene Bindungen, die noch auf ein Ergebnis warten ===");
|
||||
if (bound.Count == 0)
|
||||
if (overview.Reservations.Count == 0)
|
||||
Console.WriteLine("Keine.");
|
||||
else
|
||||
foreach (var r in bound)
|
||||
foreach (var r in overview.Reservations)
|
||||
Console.WriteLine(
|
||||
$" #{r.Id} {r.Book} {r.Side} {r.Quantity}x {r.ContractKey} – " +
|
||||
$"{r.Amount.ToString("N2", inv)} {r.Currency}, Zustand {r.State}, " +
|
||||
$"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("=== 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:");
|
||||
foreach (var o in unbacked)
|
||||
Console.WriteLine($" {o}");
|
||||
Console.WriteLine($"{overview.UnbackedOrders.Count} Order(s) liegen beim Broker, ohne dass eine Bindung sie kennt:");
|
||||
foreach (var o in overview.UnbackedOrders)
|
||||
Console.WriteLine($" {o.BrokerOrder}");
|
||||
Console.WriteLine();
|
||||
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");
|
||||
@@ -117,62 +81,32 @@ internal static class OrderBookTool
|
||||
return 0;
|
||||
}
|
||||
|
||||
var book = services.GetRequiredService<IBrokerOrderBook>();
|
||||
var manager = services.GetRequiredService<OrderManager>();
|
||||
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(ok
|
||||
? $"Order {orderId} zurückgenommen."
|
||||
: $"Order {orderId} NICHT bestätigt zurückgenommen – sie kann weiterhin liegen. " +
|
||||
"Mit --open-orders nachsehen.");
|
||||
|
||||
return ok ? 0 : 1;
|
||||
Console.WriteLine(result.Message);
|
||||
return result.Success ? 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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
Console.WriteLine("TROCKENLAUF – nichts zurückgenommen. Zum Ausführen mit --confirm wiederholen.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
var done = 0;
|
||||
foreach (var o in orders)
|
||||
{
|
||||
if (await book.CancelOrderAsync(o.OrderId, cts.Token).ConfigureAwait(false)) done++;
|
||||
}
|
||||
var manager = services.GetRequiredService<OrderManager>();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10));
|
||||
var result = await manager.CancelAllOrdersAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
Console.WriteLine($"{done} von {orders.Count} zurückgenommen.");
|
||||
Console.WriteLine("Mit --open-orders gegenprüfen – eine Bestätigung ist keine Feststellung.");
|
||||
return done == orders.Count ? 0 : 1;
|
||||
Console.WriteLine(result.Message);
|
||||
if (result.Success)
|
||||
Console.WriteLine("Mit --open-orders gegenprüfen – eine Bestätigung ist keine Feststellung.");
|
||||
else
|
||||
Console.Error.WriteLine(result.Message);
|
||||
|
||||
return result.Success ? 0 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ using IBKRTrader.Modules.CongressTrading;
|
||||
using IBKRTrader.Modules.DebugTrading;
|
||||
using IBKRTrader.Modules.OptionsWheel;
|
||||
using IBKRTrader.Modules.Supervisor;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@@ -119,10 +120,10 @@ public static class AppHostBuilder
|
||||
// 4b. Kapitalmodell: Anteile pruefen und Kapitalbindungen wiederherstellen.
|
||||
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>.
|
||||
var workerEngine = services.GetRequiredService<WorkerEngine>();
|
||||
services.GetRequiredService<WebApiService>().SetEngine(workerEngine);
|
||||
services.GetRequiredService<Web.WebserverWorker>().SetEngine(workerEngine);
|
||||
services.GetRequiredService<DcHeartbeatWorker>().SetWorkerEngine(workerEngine);
|
||||
|
||||
// 6. Deploymentcenter-Fehlermeldung verdrahten. Ohne globale Handler gab es bisher keine
|
||||
@@ -267,6 +268,7 @@ public static class AppHostBuilder
|
||||
services.AddSingleton<ReservationService>();
|
||||
services.AddSingleton<TradingRuleService>();
|
||||
services.AddSingleton<Core.Positions.PositionManager>();
|
||||
services.AddSingleton<Core.Orders.OrderManager>();
|
||||
services.AddSingleton<CapitalService>();
|
||||
services.AddSingleton<ICapitalService>(sp => sp.GetRequiredService<CapitalService>());
|
||||
|
||||
@@ -298,8 +300,11 @@ public static class AppHostBuilder
|
||||
services.AddSingleton<ReconciliationService>();
|
||||
services.AddSingleton<ReservationWatchdogWorker>();
|
||||
services.AddSingleton<PositionReconcileWorker>();
|
||||
services.AddSingleton<WebserverService>();
|
||||
services.AddSingleton<WebApiService>();
|
||||
services.AddSingleton<Web.WebserverWorker>(sp => new Web.WebserverWorker(
|
||||
sp.GetRequiredService<LoggingService>(),
|
||||
sp.GetRequiredService<IDbContextFactory<CoreDbContext>>(),
|
||||
sp.GetRequiredService<SettingsService>(),
|
||||
sp));
|
||||
services.AddSingleton<IBKRInstrumentSyncWorker>();
|
||||
services.AddSingleton<IBKRPriceHistoryWorker>();
|
||||
services.AddSingleton<DcHeartbeatWorker>();
|
||||
@@ -308,8 +313,7 @@ public static class AppHostBuilder
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<BackupWorker>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<ReservationWatchdogWorker>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<PositionReconcileWorker>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebserverService>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<WebApiService>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<Web.WebserverWorker>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
|
||||
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>());
|
||||
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<ReservationWatchdogWorker>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<PositionReconcileWorker>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<WebserverService>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<WebApiService>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<Web.WebserverWorker>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<IBKRInstrumentSyncWorker>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<IBKRPriceHistoryWorker>());
|
||||
services.AddHostedService(sp => sp.GetRequiredService<DcHeartbeatWorker>());
|
||||
|
||||
@@ -11,12 +11,19 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<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.
|
||||
Ausserhalb von systemd ein No-Op, stoert die Windows-Shell also nicht. -->
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="wwwroot\**" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\IBKRTrader.Core\IBKRTrader.Core.csproj" />
|
||||
<ProjectReference Include="..\IBKRTrader.Modules.CongressTrading\IBKRTrader.Modules.CongressTrading.csproj" />
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 > Webserver > 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>
|
||||
@@ -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();
|
||||
@@ -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());
|
||||
@@ -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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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, '"')}">
|
||||
<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.Supervisor\IBKRTrader.Modules.Supervisor.csproj" />
|
||||
<ProjectReference Include="..\..\src\IBKRTrader.Modules.DebugTrading\IBKRTrader.Modules.DebugTrading.csproj" />
|
||||
<ProjectReference Include="..\..\src\IBKRTrader.Hosting\IBKRTrader.Hosting.csproj" />
|
||||
</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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user