From f92372178456bc5671b264c25d4165b3d3d7ffe8 Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 6 Sep 2026 19:55:40 +0200 Subject: [PATCH] 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) --- .gitignore | 5 + docs/ROADMAP.md | 53 +- src/IBKRTrader.Core/Orders/OrderManager.cs | 181 ++++++ src/IBKRTrader.Core/Settings/AppSettings.cs | 21 +- .../Settings/SettingsSchema.cs | 236 ++++++++ .../Workers/BuiltIn/WebApiService.cs | 160 ------ .../Workers/BuiltIn/WebserverService.cs | 82 --- src/IBKRTrader.Daemon/OrderBookTool.cs | 122 +---- src/IBKRTrader.Hosting/AppHostBuilder.cs | 19 +- .../IBKRTrader.Hosting.csproj | 9 +- src/IBKRTrader.Hosting/Web/AuthService.cs | 179 ++++++ .../Web/Endpoints/CoreEndpoints.cs | 293 ++++++++++ .../Web/Endpoints/ModuleEndpoints.cs | 329 +++++++++++ .../Web/LogStreamService.cs | 122 +++++ src/IBKRTrader.Hosting/Web/WebserverWorker.cs | 189 +++++++ src/IBKRTrader.Hosting/wwwroot/css/app.css | 513 ++++++++++++++++++ src/IBKRTrader.Hosting/wwwroot/index.html | 68 +++ src/IBKRTrader.Hosting/wwwroot/js/api.js | 77 +++ src/IBKRTrader.Hosting/wwwroot/js/app.js | 159 ++++++ src/IBKRTrader.Hosting/wwwroot/js/auth.js | 101 ++++ .../wwwroot/js/views/accounting.js | 392 +++++++++++++ .../wwwroot/js/views/congress.js | 114 ++++ .../wwwroot/js/views/dashboard.js | 92 ++++ .../wwwroot/js/views/logs.js | 143 +++++ .../wwwroot/js/views/orders.js | 205 +++++++ .../wwwroot/js/views/positions.js | 256 +++++++++ .../wwwroot/js/views/settings.js | 149 +++++ .../wwwroot/js/views/supervisor.js | 304 +++++++++++ .../wwwroot/js/views/workers.js | 125 +++++ .../IBKRTrader.Tests/IBKRTrader.Tests.csproj | 1 + .../Orders/OrderManagerTests.cs | 218 ++++++++ .../Settings/SettingsSchemaTests.cs | 99 ++++ .../IBKRTrader.Tests/Web/AuthServiceTests.cs | 111 ++++ .../Web/LogStreamServiceTests.cs | 39 ++ .../Web/WebserverWorkerTests.cs | 60 ++ 35 files changed, 4856 insertions(+), 370 deletions(-) create mode 100644 src/IBKRTrader.Core/Orders/OrderManager.cs create mode 100644 src/IBKRTrader.Core/Settings/SettingsSchema.cs delete mode 100644 src/IBKRTrader.Core/Workers/BuiltIn/WebApiService.cs delete mode 100644 src/IBKRTrader.Core/Workers/BuiltIn/WebserverService.cs create mode 100644 src/IBKRTrader.Hosting/Web/AuthService.cs create mode 100644 src/IBKRTrader.Hosting/Web/Endpoints/CoreEndpoints.cs create mode 100644 src/IBKRTrader.Hosting/Web/Endpoints/ModuleEndpoints.cs create mode 100644 src/IBKRTrader.Hosting/Web/LogStreamService.cs create mode 100644 src/IBKRTrader.Hosting/Web/WebserverWorker.cs create mode 100644 src/IBKRTrader.Hosting/wwwroot/css/app.css create mode 100644 src/IBKRTrader.Hosting/wwwroot/index.html create mode 100644 src/IBKRTrader.Hosting/wwwroot/js/api.js create mode 100644 src/IBKRTrader.Hosting/wwwroot/js/app.js create mode 100644 src/IBKRTrader.Hosting/wwwroot/js/auth.js create mode 100644 src/IBKRTrader.Hosting/wwwroot/js/views/accounting.js create mode 100644 src/IBKRTrader.Hosting/wwwroot/js/views/congress.js create mode 100644 src/IBKRTrader.Hosting/wwwroot/js/views/dashboard.js create mode 100644 src/IBKRTrader.Hosting/wwwroot/js/views/logs.js create mode 100644 src/IBKRTrader.Hosting/wwwroot/js/views/orders.js create mode 100644 src/IBKRTrader.Hosting/wwwroot/js/views/positions.js create mode 100644 src/IBKRTrader.Hosting/wwwroot/js/views/settings.js create mode 100644 src/IBKRTrader.Hosting/wwwroot/js/views/supervisor.js create mode 100644 src/IBKRTrader.Hosting/wwwroot/js/views/workers.js create mode 100644 tests/IBKRTrader.Tests/Orders/OrderManagerTests.cs create mode 100644 tests/IBKRTrader.Tests/Settings/SettingsSchemaTests.cs create mode 100644 tests/IBKRTrader.Tests/Web/AuthServiceTests.cs create mode 100644 tests/IBKRTrader.Tests/Web/LogStreamServiceTests.cs create mode 100644 tests/IBKRTrader.Tests/Web/WebserverWorkerTests.cs diff --git a/.gitignore b/.gitignore index 41e9833..d936b72 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,8 @@ artifacts/ ## Deploymentcenter-Token aus der Selbst-Provisionierung (Werkbank + Laufzeit) .dc-tokens.local.json + +## Python +__pycache__/ +*.pyc + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1f18dd0..403bf3c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -93,7 +93,7 @@ Strike-Wahl delta-basiert im Band 0,15–0,30. Nur Watchlist, kein Screening. --- - + + + + + diff --git a/src/IBKRTrader.Hosting/Web/AuthService.cs b/src/IBKRTrader.Hosting/Web/AuthService.cs new file mode 100644 index 0000000..7e9dcd3 --- /dev/null +++ b/src/IBKRTrader.Hosting/Web/AuthService.cs @@ -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; + +/// +/// Authentifizierungsdienst für das Web-UI und die REST-API. +/// Validiert den konfigurierten Auth-Token und erzeugt signierte Sitzungs-Tokens (HMAC-SHA256). +/// +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); + } + } + + /// Der wirksame Auth-Token aus settings.json oder Umgebungsvariable. + public string ConfiguredToken + { + get + { + var env = Environment.GetEnvironmentVariable("IBKRTRADER_WEB_TOKEN"); + if (!string.IsNullOrWhiteSpace(env)) return env.Trim(); + return _settings.Settings.Webserver.AuthToken.Trim(); + } + } + + /// Ist ein Auth-Token eingerichtet? + public bool IsConfigured => !string.IsNullOrWhiteSpace(ConfiguredToken); + + /// + /// Prüft einen eingegebenen Token gegen den konfigurierten Wert (zeitkonstanter Vergleich). + /// + 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); + } + + /// + /// Erzeugt ein signiertes Session-Token mit Ablaufzeit (standardmäßig 7 Tage). + /// + 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}"; + } + + /// + /// Prüft, ob ein Session-Token gültig und noch nicht abgelaufen ist. + /// + 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; + } + } + + /// + /// Prüft, ob die HTTP-Anfrage authentifiziert ist (Header, Cookie oder Direkt-Token). + /// + public bool IsAuthenticated(HttpContext context) + { + if (!IsConfigured) return false; + + // 1. Authorization: Bearer + 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= + 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; + } + + /// Setzt das Session-Cookie in der Response. + 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 = "/" + }); + } + + /// Löscht das Session-Cookie in der Response. + public void ClearAuthCookie(HttpContext context) + { + context.Response.Cookies.Delete(CookieName, new CookieOptions + { + Path = "/" + }); + } +} diff --git a/src/IBKRTrader.Hosting/Web/Endpoints/CoreEndpoints.cs b/src/IBKRTrader.Hosting/Web/Endpoints/CoreEndpoints.cs new file mode 100644 index 0000000..832a855 --- /dev/null +++ b/src/IBKRTrader.Hosting/Web/Endpoints/CoreEndpoints.cs @@ -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; + +/// +/// Registriert sämtliche Core-Endpunkte der Web-API. +/// +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 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(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 Updates); diff --git a/src/IBKRTrader.Hosting/Web/Endpoints/ModuleEndpoints.cs b/src/IBKRTrader.Hosting/Web/Endpoints/ModuleEndpoints.cs new file mode 100644 index 0000000..11d4717 --- /dev/null +++ b/src/IBKRTrader.Hosting/Web/Endpoints/ModuleEndpoints.cs @@ -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; + +/// +/// Registriert die Modul-Endpunkte der Web-API. +/// +/// Architektur-Regel: Der Core darf die Module nicht kennen. IBKRTrader.Hosting kennt +/// sie und registriert deren Routen zentral, analog zu ModuleViews.cs in der Shell. +/// +public static class ModuleEndpoints +{ + public static void Map(RouteGroupBuilder api, IServiceProvider sp) + { + var modules = sp.GetServices().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); diff --git a/src/IBKRTrader.Hosting/Web/LogStreamService.cs b/src/IBKRTrader.Hosting/Web/LogStreamService.cs new file mode 100644 index 0000000..6e6e4c0 --- /dev/null +++ b/src/IBKRTrader.Hosting/Web/LogStreamService.cs @@ -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; + +/// +/// Verteilt Log-Ereignisse über Server-Sent Events (SSE) an Web-Browser. +/// +/// Entscheidung für mehrere Browser-Clients: +/// Jeder verbundene Browser erhält einen eigenen isolierten 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. +/// +public sealed class LogStreamService : IDisposable +{ + private const int RingBufferSize = 100; + private const int ClientChannelCapacity = 500; + + private readonly LoggingService _logger; + private readonly ConcurrentDictionary> _clients = new(); + private readonly ConcurrentQueue _recentBuffer = new(); + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public LogStreamService(LoggingService logger) + { + _logger = logger; + _logger.EntryWritten += OnEntryWritten; + } + + /// Liefert die kürzlich geschriebenen Log-Einträge. + public IReadOnlyList 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); + } + } + + /// + /// Bedient eine SSE-Verbindung (text/event-stream) für einen verbundenen Browser. + /// + 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(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(); + } +} diff --git a/src/IBKRTrader.Hosting/Web/WebserverWorker.cs b/src/IBKRTrader.Hosting/Web/WebserverWorker.cs new file mode 100644 index 0000000..f5cf871 --- /dev/null +++ b/src/IBKRTrader.Hosting/Web/WebserverWorker.cs @@ -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; + +/// +/// Einheitlicher Webserver für Web-UI und REST-API auf Basis von ASP.NET Core Minimal APIs und Kestrel. +/// +/// Ersetzt die bisherigen getrennten HttpListener-Dienste (WebserverService und WebApiService). +/// Läuft als permanenter Service in beiden Betriebsformen (Desktop und Daemon). +/// +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 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"; + } + + /// Setzt die WorkerEngine nach dem Bau des Hosts (zirkuläre Abhängigkeit auflösen). + 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()); + builder.Services.AddSingleton(_rootServices.GetRequiredService()); + builder.Services.AddSingleton(_rootServices.GetRequiredService()); + builder.Services.AddSingleton(_rootServices.GetRequiredService()); + builder.Services.AddSingleton(_rootServices.GetServices()); + builder.Services.AddSingleton(sp => _engine ?? _rootServices.GetRequiredService()); + + // Optionale Modul-Dienste weiterreichen + ForwardOptional(builder.Services); + ForwardOptional(builder.Services); + ForwardOptional(builder.Services); + ForwardOptional(builder.Services); + ForwardOptional(builder.Services); + ForwardOptional(builder.Services); + ForwardOptional(builder.Services); + ForwardOptional(builder.Services); + ForwardOptional(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(); + 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(IServiceCollection services) where T : class + { + var instance = _rootServices.GetService(); + if (instance != null) services.AddSingleton(instance); + } + + public void Dispose() + { + _logStreamService.Dispose(); + _app?.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } +} diff --git a/src/IBKRTrader.Hosting/wwwroot/css/app.css b/src/IBKRTrader.Hosting/wwwroot/css/app.css new file mode 100644 index 0000000..fbdeaa7 --- /dev/null +++ b/src/IBKRTrader.Hosting/wwwroot/css/app.css @@ -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; +} diff --git a/src/IBKRTrader.Hosting/wwwroot/index.html b/src/IBKRTrader.Hosting/wwwroot/index.html new file mode 100644 index 0000000..84bd1ae --- /dev/null +++ b/src/IBKRTrader.Hosting/wwwroot/index.html @@ -0,0 +1,68 @@ + + + + + + IBKRTrader + + + + + +
+
+ IBKRTrader + PAPER +
+ + + +
+ +
+
+ + +
+ + +
+ Bereit. +
+ ● Verbunden + --:--:-- UTC +
+
+ + + + + + + diff --git a/src/IBKRTrader.Hosting/wwwroot/js/api.js b/src/IBKRTrader.Hosting/wwwroot/js/api.js new file mode 100644 index 0000000..9a0747e --- /dev/null +++ b/src/IBKRTrader.Hosting/wwwroot/js/api.js @@ -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(); diff --git a/src/IBKRTrader.Hosting/wwwroot/js/app.js b/src/IBKRTrader.Hosting/wwwroot/js/app.js new file mode 100644 index 0000000..26535b0 --- /dev/null +++ b/src/IBKRTrader.Hosting/wwwroot/js/app.js @@ -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 = '
Lade Ansicht …
'; + + 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 = ` +
+

Fehler beim Laden der Ansicht

+

${err.message}

+
+ `; + } + } +} + +// Start application +const app = new App(); +document.addEventListener('DOMContentLoaded', () => app.init()); diff --git a/src/IBKRTrader.Hosting/wwwroot/js/auth.js b/src/IBKRTrader.Hosting/wwwroot/js/auth.js new file mode 100644 index 0000000..ec8fe7e --- /dev/null +++ b/src/IBKRTrader.Hosting/wwwroot/js/auth.js @@ -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(); diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/accounting.js b/src/IBKRTrader.Hosting/wwwroot/js/views/accounting.js new file mode 100644 index 0000000..2acd23d --- /dev/null +++ b/src/IBKRTrader.Hosting/wwwroot/js/views/accounting.js @@ -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 = ` +
+
+ + + + + +
+
+
+ + +
+ + + + + +
+ +
+ +
+ `; + + // 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 = '' + + accounts.map(a => ``).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 = ` +
+
Netto-Ergebnis
–
+
Handelsvolumen
–
+
Dividenden
–
+
Gebühren / Fees
–
+
Endsaldo
–
+
Trades / Buchungen
–
+
+
+
+
+ Monatsvergleich +
+ + + + + + + + + + + + + + + + +
MonatAnfangssaldoEinzahlungenAuszahlungenVolumenFeesErgebnisEndsaldo
Lade Monatsvergleich …
+
+ `; + await this.loadOverview(); + } else if (this.currentSubTab === 'ledger') { + sub.innerHTML = ` +
+ + + + + + + + + + + + + + + + + + + + +
Zeit (UTC)KontoTypSideSymbolWährungMengePreisBruttoFeeNettoTransaktion
Lade Ledger …
+
+ `; + await this.loadLedger(); + } else if (this.currentSubTab === 'tax') { + sub.innerHTML = ` +
+

Steuerliche Einordnung

+

+ Die steuerliche Einordnung ist noch offen (Jurisdiktion nicht festgelegt). +

+

+ 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. +

+
+ Hinweis: Dies ist keine Steuerberatung. Alle Daten basieren auf den Buchungseinträgen des Ledgers. +
+
+ `; + } 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 = ` +
+

Abrechnung & Export

+

+ Exportiert die aktuelle Auswahl (Zeitraum: ${f.from} bis ${f.to} | Konto: ${f.account} | Währung: ${f.currency}): +

+ +
+ `; + } else if (this.currentSubTab === 'runs') { + sub.innerHTML = ` +
+
+ + +
+
+
+
+ + + + + + + + + + + + + + + + + +
KontoStartEndeBackfillNeuDuplikateOKDeltaMeldung
Lade Abruf-Historie …
+
+ `; + 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 = `Keine Monatsdaten im gewählten Zeitraum.`; + } else { + tbody.innerHTML = monthly.map(m => ` + + ${m.month} + ${F(m.openingBalance)} + ${F(m.deposits)} + ${F(m.withdrawals)} + ${F(m.tradeVolume)} + ${F(m.fees)} + ${F(m.netTradingResult)} + ${F(m.closingBalance)} + + `).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 = `Keine Ledger-Einträge im gewählten Zeitraum.`; + } else { + tbody.innerHTML = entries.map(e => ` + + ${e.timestamp} + ${e.accountId} + ${e.eventType} + ${e.side || '–'} + ${e.symbol || '–'} + ${e.currency || '–'} + ${e.quantity !== null && e.quantity !== undefined ? Number(e.quantity).toLocaleString('de-DE') : '–'} + ${e.priceNative !== null && e.priceNative !== undefined ? Number(e.priceNative).toLocaleString('de-DE', { minimumFractionDigits: 2 }) : '–'} + ${Number(e.grossBase || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })} + ${Number(e.feeBase || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })} + ${Number(e.netBase || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })} + ${e.transactionId || '–'} + + `).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 = `Keine Abrufe vorhanden.`; + } else { + tbody.innerHTML = runs.map(r => ` + + ${r.accountId} + ${r.startedAt} + ${r.finishedAt} + ${r.backfill ? 'Ja' : 'Nein'} + ${Number(r.newEntries || 0).toLocaleString('de-DE')} + ${Number(r.duplicateEntries || 0).toLocaleString('de-DE')} + ${r.success ? 'OK' : 'Fehler'} + ${Number(r.balanceDeltaBase || 0).toLocaleString('de-DE', { minimumFractionDigits: 2 })} + ${r.message || '–'} + + `).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}`; + } + } +} diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/congress.js b/src/IBKRTrader.Hosting/wwwroot/js/views/congress.js new file mode 100644 index 0000000..f68125b --- /dev/null +++ b/src/IBKRTrader.Hosting/wwwroot/js/views/congress.js @@ -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 = ` +
+
+ + +
+
+
+ +
+
Trades gesamt
–
+
Kongressmitglieder
–
+
Offene Positionen
–
+
Investiertes Volumen
–
+
+ +
+
+ Offene Positionen (Modul CT) +
+ + + + + + + + + + + + +
SymbolStückØ-KursWert (USD)
Lade Daten …
+
+ `; + + 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 = `Keine offenen Positionen im Buch CongressTrading.`; + } else { + tbody.innerHTML = positions.map(p => ` + + ${p.symbol} + ${Number(p.quantity).toLocaleString('de-DE')} + ${Number(p.avgPrice).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} $ + ${Number(p.notional).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} $ + + `).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'; + } + } +} diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/dashboard.js b/src/IBKRTrader.Hosting/wwwroot/js/views/dashboard.js new file mode 100644 index 0000000..4642629 --- /dev/null +++ b/src/IBKRTrader.Hosting/wwwroot/js/views/dashboard.js @@ -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 = ` +
+
+ + +
+
+
+ +
+
Offene Positionen
–
+
Exposure
–
+
Trades gesamt
–
+
Worker / Services
–
+
+ +
+ + + + + + + + + + + +
ModulDB-PräfixStatus / Blocker
Lade Daten …
+
+ `; + + 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 = `Keine Module geladen.`; + } else { + tbody.innerHTML = data.modules.map(m => ` + + ${m.name} + ${m.dbPrefix} + ${m.blocker === 'aktivierbar' ? 'aktivierbar' : `${m.blocker}`} + + `).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}`); + } + } +} diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/logs.js b/src/IBKRTrader.Hosting/wwwroot/js/views/logs.js new file mode 100644 index 0000000..9ef4e8c --- /dev/null +++ b/src/IBKRTrader.Hosting/wwwroot/js/views/logs.js @@ -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 = ` +
+
+ + + + +
+
0 Zeilen
+
+ +
+ `; + + 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; + } + } +} diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/orders.js b/src/IBKRTrader.Hosting/wwwroot/js/views/orders.js new file mode 100644 index 0000000..7624740 --- /dev/null +++ b/src/IBKRTrader.Hosting/wwwroot/js/views/orders.js @@ -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 = ` +
+
+ + + +
+
+
+ +
+ + + +
+
+ Offene Orders beim Broker +
+ + + + + + + + + + + + + + + + + + +
Order-IDSymbolRichtungMengeGefülltOffenTypLimitStatusEigene Kapitalbindung
Lade Orders …
+ +
+ Eigene Kapitalbindungen (Reservierungen) +
+ + + + + + + + + + + + + + + + +
IDBuchRichtungMengeKontraktBetragZustandBroker Order-ID
Lade Bindungen …
+
+
+ `; + + 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 = `Keine offenen Orders beim Broker.`; + } else { + ordTbody.innerHTML = this.orders.map((o, idx) => ` + + #${o.orderId} + ${o.symbol} + ${o.side === 'Buy' ? 'Kauf' : 'Verkauf'} + ${Number(o.quantity).toLocaleString('de-DE')} + ${Number(o.filled).toLocaleString('de-DE')} + ${Number(o.remaining).toLocaleString('de-DE')} + ${o.orderType} + ${o.limitPrice !== null ? Number(o.limitPrice).toLocaleString('de-DE', { minimumFractionDigits: 2 }) : '–'} + ${o.status} + ${o.isBackedByReservation ? `✔ Gedeckt durch #${o.reservationId} (${o.book})` : 'UNGEDECKT (keine Bindung!)'} + + `).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 = `Keine offenen Kapitalbindungen.`; + } else { + resTbody.innerHTML = reservations.map(r => ` + + #${r.id} + ${r.book} + ${r.side} + ${Number(r.quantity).toLocaleString('de-DE')} + ${r.contractKey} + ${Number(r.amount).toLocaleString('de-DE', { minimumFractionDigits: 2 })} ${r.currency} + ${r.state} + ${r.brokerOrderId ? `#${r.brokerOrderId}` : '(noch keine)'} + + `).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}`); + } + } +} diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/positions.js b/src/IBKRTrader.Hosting/wwwroot/js/views/positions.js new file mode 100644 index 0000000..c1a2a03 --- /dev/null +++ b/src/IBKRTrader.Hosting/wwwroot/js/views/positions.js @@ -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 = ` +
+
+ + +
+
+
+ +
+ +
+ Sperre: + + +
+ + +
+ Umbuchen nach: + + + +
+
+ +
+ + + + + + + + + + + + + + + + + +
BuchHerkunftInstrumentMengeEinstandGebundenBrokerAbweichungSperren & Einschränkungen
Lade Positionen …
+ + +
+ `; + + 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 = `Keine offenen Positionen.`; + } else { + tbody.innerHTML = this.positions.map((p, idx) => ` + + ${p.book} + ${p.origin} + ${p.instrument} + ${Number(p.quantity).toLocaleString('de-DE')} + ${Number(p.avgPrice).toLocaleString('de-DE', { minimumFractionDigits: 2 })} + ${Number(p.boundCapital).toLocaleString('de-DE', { maximumFractionDigits: 0 })} ${p.currency} + ${p.brokerQuantity !== null ? Number(p.brokerQuantity).toLocaleString('de-DE') : '–'} + ${p.diverges ? 'Abweichung' : '–'} + ${this.formatRestrictions(p)} + + `).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 => ` + + ${u.symbol} + ${u.secType} + ${u.currency} + ${Number(u.quantity).toLocaleString('de-DE')} + ${Number(u.avgCost).toLocaleString('de-DE', { minimumFractionDigits: 2 })} + Bestand ohne Buch. Beim Abgleich wird er nach UNASSIGNED gebucht. + + `).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(`${p.positionLock}`); + } + if (p.blocked) { + parts.push(`Gesperrt`); + } + 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}`); + } + } +} diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/settings.js b/src/IBKRTrader.Hosting/wwwroot/js/views/settings.js new file mode 100644 index 0000000..d83a5ed --- /dev/null +++ b/src/IBKRTrader.Hosting/wwwroot/js/views/settings.js @@ -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 = ` +
+
+ + +
+
+
+ +
+
Lade Einstellungen …
+
+ `; + + 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) => ` +
+
+ ${section.title} + ▾ +
+
+ ${section.fields.map(f => this.renderField(f)).join('')} +
+
+ `).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 = ``; + } else if (f.typeName === 'enum' && f.enumValues) { + controlHtml = ` + + `; + } else if (f.isPassword) { + controlHtml = ` + + `; + } else if (f.typeName === 'int' || f.typeName === 'decimal') { + controlHtml = ` + + `; + } else { + controlHtml = ` + + `; + } + + return ` +
+
${f.displayName}
+ ${f.description ? `
${f.description}
` : ''} +
+
${controlHtml}
+ `; + } + + escapeHtml(str) { + return String(str) + .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}`); + } + } +} diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/supervisor.js b/src/IBKRTrader.Hosting/wwwroot/js/views/supervisor.js new file mode 100644 index 0000000..2a8b418 --- /dev/null +++ b/src/IBKRTrader.Hosting/wwwroot/js/views/supervisor.js @@ -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 = ` + +
+ + + + +
+ +
+ +
+ `; + + 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 = ` +
+
+ Profil: + + +
+
+
+ +
+ +
+ +
+ Wählen Sie ein Profil, geben Sie eine Frage ein und klicken Sie auf 'Fragen'. +
+ `; + + 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 = ` +
+ +
+
+ +
+
+ + + + + + + + + + + + +
ZeitSymbolModulEntscheidung
Signale laden …
+
+
+ + +
+
+ Dossier-Details +
+
+ Signal in der linken Tabelle auswählen. +
+
+
+ `; + + document.getElementById('sup-load-signals-btn').addEventListener('click', () => this.loadSignals()); + await this.loadSignals(); + } else if (this.currentSubTab === 'reports') { + sub.innerHTML = ` +
+ +
+
+
+ + + + + + + + + + + + + + +
ErstelltProfilModellFrageToolsTokens
Lade Berichte …
+
+ `; + + document.getElementById('sup-load-reports-btn').addEventListener('click', () => this.loadReports()); + await this.loadReports(); + } else if (this.currentSubTab === 'info') { + sub.innerHTML = ` +
+

Supervisor Modul-Hinweise

+
Lade Modul-Informationen …
+
+ `; + 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 = `Keine Signale gefunden.`; + } else { + tbody.innerHTML = this.signals.map((s, idx) => ` + + ${s.firstSeen ? s.firstSeen.substring(5, 16) : '–'} + ${s.symbol || '–'} + ${s.module || '–'} + ${s.lastDecision || '–'} + + `).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 = `Keine Berichte vorhanden.`; + } else { + tbody.innerHTML = reports.map(r => ` + + ${r.createdAt} + ${r.profile} + ${r.model} + ${r.question} + ${r.toolCallCount} + ${r.tokens} + + `).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 = ` +
+ OpenRouter API-Key: ${info.openRouterKeyConfigured ? '✅ Konfiguriert' : '⚠️ Nicht gesetzt (in settings.json oder OPENROUTER_API_KEY hinterlegen)'} +
+

Zeitzone: ${info.timeZone || '–'}

+

Beschreibung: ${info.description || '–'}

+ `; + } catch (e) { + console.error(e); + } + } +} diff --git a/src/IBKRTrader.Hosting/wwwroot/js/views/workers.js b/src/IBKRTrader.Hosting/wwwroot/js/views/workers.js new file mode 100644 index 0000000..fd1a23e --- /dev/null +++ b/src/IBKRTrader.Hosting/wwwroot/js/views/workers.js @@ -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 = ` +
+
+ + +
+
+
+ +
+ + + + + + + + + + + + + + + + +
AktivTypModulWorkerLetzter LaufNächster LaufIntervallInfo
Lade Worker …
+
+ `; + + 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 = `Keine Worker registriert.`; + return; + } + + tbody.innerHTML = data.map((w, idx) => ` + + ${w.active ? '✔' : '–'} + ${w.type} + ${w.module} + ${w.workerName} + ${w.lastRuntime || '–'} + ${w.nextRuntime || '–'} + ${w.runEvery || '–'} + ${w.info || '–'} + + `).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}`); + } + } +} diff --git a/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj b/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj index 2a87987..14fa1b9 100644 --- a/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj +++ b/tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj @@ -28,6 +28,7 @@ + diff --git a/tests/IBKRTrader.Tests/Orders/OrderManagerTests.cs b/tests/IBKRTrader.Tests/Orders/OrderManagerTests.cs new file mode 100644 index 0000000..594b04b --- /dev/null +++ b/tests/IBKRTrader.Tests/Orders/OrderManagerTests.cs @@ -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 + { + private readonly DbContextOptions _options; + public InMemoryFactory(DbContextOptions options) => _options = options; + public CoreDbContext CreateDbContext() => new(_options); + } + + private static (OrderManager Sut, IBrokerOrderBook OrderBook, IDbContextFactory Dbf, SettingsService Settings) CreateSut() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var factory = new InMemoryFactory(options); + var orderBook = Substitute.For(); + 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()) + .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()).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()); + } + + [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()); + } + + [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()).Returns([bo1, bo2]); + orderBook.CancelOrderAsync(Arg.Any(), Arg.Any()).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()); + await orderBook.Received(1).CancelOrderAsync(202, Arg.Any()); + } + + [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()).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()) + .Returns>(_ => 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()).Returns(false); + + var result = await sut.CancelOrderAsync(999); + + result.Success.Should().BeFalse(); + result.Message.Should().Contain("NICHT bestätigt"); + } +} + + diff --git a/tests/IBKRTrader.Tests/Settings/SettingsSchemaTests.cs b/tests/IBKRTrader.Tests/Settings/SettingsSchemaTests.cs new file mode 100644 index 0000000..921663c --- /dev/null +++ b/tests/IBKRTrader.Tests/Settings/SettingsSchemaTests.cs @@ -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(); + } +} + diff --git a/tests/IBKRTrader.Tests/Web/AuthServiceTests.cs b/tests/IBKRTrader.Tests/Web/AuthServiceTests.cs new file mode 100644 index 0000000..160d7ee --- /dev/null +++ b/tests/IBKRTrader.Tests/Web/AuthServiceTests.cs @@ -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(); + } +} + diff --git a/tests/IBKRTrader.Tests/Web/LogStreamServiceTests.cs b/tests/IBKRTrader.Tests/Web/LogStreamServiceTests.cs new file mode 100644 index 0000000..5c20320 --- /dev/null +++ b/tests/IBKRTrader.Tests/Web/LogStreamServiceTests.cs @@ -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); + } +} diff --git a/tests/IBKRTrader.Tests/Web/WebserverWorkerTests.cs b/tests/IBKRTrader.Tests/Web/WebserverWorkerTests.cs new file mode 100644 index 0000000..ed7482b --- /dev/null +++ b/tests/IBKRTrader.Tests/Web/WebserverWorkerTests.cs @@ -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 + { + private readonly DbContextOptions _options; + public InMemoryFactory(DbContextOptions 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() + .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(); + + 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"); + } +}