using IBKRTrader.Core.Configuration; using IBKRTrader.Core.Analytics; using IBKRTrader.Core.Logging; using IBKRTrader.Core.Persistence; namespace IBKRTrader.Modules.Supervisor.Services; /// Kopfzeile eines Signals für die Übersichtsliste des Dossier-Browsers. public sealed record SignalSummary( string SignalId, DateTime FirstSeen, string Module, string Symbol, string Side, string LastDecision, string LastReason, int DecisionCount); /// /// Beschafft die Daten für Trade-Dossiers: Entscheidungsjournal + Order-Events + Trade-Log + /// JSONL-Log-Zeilen (per CorrelationId), Zusammenbau/Rendering pur im /// (Core). Read-only — der Supervisor ist Beobachter. /// public sealed class DossierService { private readonly IDecisionJournal _journal; private readonly IOrderEventLog _orderEvents; private readonly TradeLogReader _trades; private readonly string _logsDirectory; public DossierService(IDecisionJournal journal, IOrderEventLog orderEvents, TradeLogReader trades) { _journal = journal; _orderEvents = orderEvents; _trades = trades; _logsDirectory = AppPaths.Logs; } /// Jüngste Signale (gruppiert über das Entscheidungsjournal), neueste zuerst. public List RecentSignals(int limit = 200) { var decisions = _journal.Query(d => d.SignalId != "", limit * 5); return decisions .GroupBy(d => d.SignalId) .Select(g => { var ordered = g.OrderBy(d => d.Timestamp).ToList(); var first = ordered[0]; var last = ordered[^1]; return new SignalSummary(g.Key, first.Timestamp, first.Module, first.Symbol, first.Side, last.Decision.ToString(), last.Reason.ToString(), ordered.Count); }) .OrderByDescending(s => s.FirstSeen) .Take(limit) .ToList(); } /// Baut das komplette Dossier zu einer SignalId (inkl. Log-Zeilen aus den JSONL-Tagesdateien). public TradeDossier BuildForSignal(string signalId) { var decisions = _journal.Query(d => d.SignalId == signalId); var events = _orderEvents.Query(e => e.SignalId == signalId); var trades = _trades.BySignal(signalId); var logLines = ReadLogLines(signalId, decisions.Select(d => d.Timestamp).Concat(events.Select(e => e.Timestamp))); return DossierBuilder.Build(signalId, decisions, events, trades, logLines); } /// /// Liest JSONL-Zeilen mit passender CorrelationId — nur aus den Tagesdateien im Zeitfenster der /// bekannten Ereignisse (±1 Tag), statt alle Logs zu scannen. Fehlertolerant (fehlende Dateien = leer). /// private List ReadLogLines(string signalId, IEnumerable eventTimes) { var result = new List(); var times = eventTimes.ToList(); if (times.Count == 0 || string.IsNullOrEmpty(signalId)) return result; try { var from = times.Min().Date.AddDays(-1); var to = times.Max().Date.AddDays(1); for (var day = from; day <= to; day = day.AddDays(1)) { // Muss zeichengleich zu dem sein, was LoggingService schreibt - sonst sucht der // Supervisor unter einem Namen, den es nicht gibt. string path = Path.Combine( _logsDirectory, FormattableString.Invariant($"{day:yyyy-MM-dd}.jsonl")); if (!File.Exists(path)) continue; foreach (var line in File.ReadLines(path)) { var p = LogJson.ParseLine(line); if (p != null && p.Cid == signalId) result.Add(p); } } } catch { /* Log-Auszug ist Beiwerk – Dossier bleibt auch ohne nutzbar */ } return result; } }