diff --git a/.gitignore b/.gitignore
index 9424cb6..490b87e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,6 +31,7 @@ MongoDB/
# Gitea Personal Access Token für Pushes – niemals committen
.gitea-token
master.key
+openrouter.key
# ── Logs & temporäre Dateien ─────────────────────
*.log
diff --git a/docs/sicherheit/SICHERHEITSKONZEPT.md b/docs/sicherheit/SICHERHEITSKONZEPT.md
index 4329f33..7c2531d 100644
--- a/docs/sicherheit/SICHERHEITSKONZEPT.md
+++ b/docs/sicherheit/SICHERHEITSKONZEPT.md
@@ -45,10 +45,15 @@ gefunden. Ausgehende Verbindungen (Code-Audit der URLs):
gebroadcastete Transaktionen.
- **Eigene MySQL-DB** (remote gehostet) — eigene Infrastruktur, kein Dritt-Dienst im engeren Sinn.
- **Mullvad VPN** — bewusstes Datenschutz-Tool (Routing).
-- **Threema Gateway** (`msgapi.threema.ch`) — **einziger Nicht-Polymarket-Dienst, der App-Inhalte
- empfängt** (unsere Benachrichtigungstexte). Bewusstes Feature; end-to-end-verschlüsselt.
-→ **Keine versteckte Datenweitergabe.** OpenRouter/AI-Anbindung existiert nur im separaten
-Predictalytics-Projekt, **nicht** in PolyTrader.
+- **Threema Gateway** (`msgapi.threema.ch`) — App-Inhalte: unsere Benachrichtigungstexte.
+ Bewusstes Feature; end-to-end-verschlüsselt.
+- **OpenRouter** (`openrouter.ai`, seit S-2 Supervisor-Modul) — **bewusst freigegebener** externer
+ Datenempfänger für die KI-Analyse: Es werden ausschließlich die Ergebnisse der read-only-Analyse-Tools
+ gesendet (Entscheidungsjournal, Order-Events, Trades, Log-Auszüge, KPIs, Architektur-Doku) — **niemals
+ Secrets/Keys** (Logs sind per F6 secret-frei). API-Key getrennt vom Trading
+ (env `POLYTRADER_OPENROUTER_KEY` bzw. gitignorierte `openrouter.key`); Spend-Limit je Key bei
+ OpenRouter setzen. Der Agent ist strikt read-only (keine Handels-Tools).
+→ **Keine versteckte Datenweitergabe** — jede neue ausgehende Verbindung wird hier dokumentiert.
---
diff --git a/src/PolyTrader.Modules.Supervisor/Agent/ArchitectureContext.cs b/src/PolyTrader.Modules.Supervisor/Agent/ArchitectureContext.cs
new file mode 100644
index 0000000..e09eb7f
--- /dev/null
+++ b/src/PolyTrader.Modules.Supervisor/Agent/ArchitectureContext.cs
@@ -0,0 +1,23 @@
+using System.IO;
+using System.Reflection;
+
+namespace PolyTrader.Modules.Supervisor.Agent
+{
+ ///
+ /// Lädt das kuratierte Architektur-/Verhaltensdokument (Context/ArchitectureContext.md,
+ /// als EmbeddedResource versioniert mit dem Code deployt). Es ist der System-Kontext des
+ /// Agenten — bei Änderungen am Geld-Pfad mitpflegen.
+ ///
+ public static class ArchitectureContext
+ {
+ public static string Load()
+ {
+ var asm = Assembly.GetExecutingAssembly();
+ const string resource = "PolyTrader.Modules.Supervisor.Context.ArchitectureContext.md";
+ using var stream = asm.GetManifestResourceStream(resource);
+ if (stream == null) return "(Architektur-Kontext-Ressource nicht gefunden)";
+ using var reader = new StreamReader(stream);
+ return reader.ReadToEnd();
+ }
+ }
+}
diff --git a/src/PolyTrader.Modules.Supervisor/Agent/ChatModels.cs b/src/PolyTrader.Modules.Supervisor/Agent/ChatModels.cs
new file mode 100644
index 0000000..2e1443f
--- /dev/null
+++ b/src/PolyTrader.Modules.Supervisor/Agent/ChatModels.cs
@@ -0,0 +1,36 @@
+using System.Collections.Generic;
+
+namespace PolyTrader.Modules.Supervisor.Agent
+{
+ /// Chat-Nachricht im OpenAI-/OpenRouter-Schema (Rollen: system/user/assistant/tool).
+ public sealed class ChatMessage
+ {
+ public string Role { get; init; } = "user";
+ public string? Content { get; init; }
+
+ /// Vom Modell angeforderte Tool-Aufrufe (nur Rolle assistant).
+ public List? ToolCalls { get; init; }
+
+ /// Bezug auf den beantworteten Tool-Aufruf (nur Rolle tool).
+ public string? ToolCallId { get; init; }
+
+ public static ChatMessage System(string content) => new() { Role = "system", Content = content };
+ public static ChatMessage User(string content) => new() { Role = "user", Content = content };
+ public static ChatMessage Assistant(string? content, List? toolCalls = null) =>
+ new() { Role = "assistant", Content = content, ToolCalls = toolCalls };
+ public static ChatMessage ToolResult(string toolCallId, string content) =>
+ new() { Role = "tool", ToolCallId = toolCallId, Content = content };
+ }
+
+ /// Ein Tool-Aufruf des Modells (Function-Calling).
+ public sealed record ToolCall(string Id, string Name, string ArgumentsJson);
+
+ /// Antwort des Modells: Text ODER Tool-Aufrufe (oder beides).
+ public sealed class ChatResponse
+ {
+ public string? Content { get; init; }
+ public List ToolCalls { get; init; } = new();
+ public int PromptTokens { get; init; }
+ public int CompletionTokens { get; init; }
+ }
+}
diff --git a/src/PolyTrader.Modules.Supervisor/Agent/OpenRouterClient.cs b/src/PolyTrader.Modules.Supervisor/Agent/OpenRouterClient.cs
new file mode 100644
index 0000000..a414506
--- /dev/null
+++ b/src/PolyTrader.Modules.Supervisor/Agent/OpenRouterClient.cs
@@ -0,0 +1,169 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PolyTrader.Modules.Supervisor.Agent
+{
+ /// Chat-Completion-Client (Function-Calling). Interface, damit der Agent testbar ist.
+ public interface IChatCompletionClient
+ {
+ Task CompleteAsync(string model, IReadOnlyList messages,
+ IReadOnlyList tools, CancellationToken ct);
+ }
+
+ ///
+ /// OpenRouter-Client (OpenAI-kompatibles Chat-Completions-Schema inkl. Tools).
+ /// API-Key: Umgebungsvariable POLYTRADER_OPENROUTER_KEY, sonst gitignorierte Datei
+ /// openrouter.key im App-Ordner — GETRENNT vom Trading-Key (siehe Supervisor-Konzept §5).
+ /// SICHERHEIT: OpenRouter ist ein bewusst freigegebener externer Datenempfänger
+ /// (Egress-Allowlist, docs/sicherheit); es werden ausschließlich Analyse-Daten der
+ /// Tools gesendet, niemals Secrets.
+ ///
+ public sealed class OpenRouterClient : IChatCompletionClient
+ {
+ public const string Endpoint = "https://openrouter.ai/api/v1/chat/completions";
+
+ private readonly HttpClient _http;
+ private readonly Func _apiKeyProvider;
+
+ public OpenRouterClient(HttpClient http, Func? apiKeyProvider = null)
+ {
+ _http = http;
+ _apiKeyProvider = apiKeyProvider ?? DefaultApiKeyProvider;
+ }
+
+ /// Key aus env POLYTRADER_OPENROUTER_KEY, sonst aus gitignorierter openrouter.key.
+ public static string? DefaultApiKeyProvider()
+ {
+ string? key = Environment.GetEnvironmentVariable("POLYTRADER_OPENROUTER_KEY");
+ if (!string.IsNullOrWhiteSpace(key)) return key.Trim();
+ string file = Path.Combine(AppContext.BaseDirectory, "openrouter.key");
+ return File.Exists(file) ? File.ReadAllText(file).Trim() : null;
+ }
+
+ public async Task CompleteAsync(string model, IReadOnlyList messages,
+ IReadOnlyList tools, CancellationToken ct)
+ {
+ string? apiKey = _apiKeyProvider();
+ if (string.IsNullOrWhiteSpace(apiKey))
+ throw new InvalidOperationException(
+ "Kein OpenRouter-API-Key. Setze POLYTRADER_OPENROUTER_KEY (Umgebungsvariable) oder lege " +
+ "die Datei 'openrouter.key' in den App-Ordner (gitignored). Separater Key für den Supervisor empfohlen.");
+
+ string body = BuildRequestBody(model, messages, tools);
+ using var request = new HttpRequestMessage(HttpMethod.Post, Endpoint);
+ request.Headers.Add("Authorization", $"Bearer {apiKey}");
+ request.Headers.Add("X-Title", "PolyTrader Supervisor");
+ request.Content = new StringContent(body, Encoding.UTF8, "application/json");
+
+ using var response = await _http.SendAsync(request, ct);
+ string json = await response.Content.ReadAsStringAsync(ct);
+ if (!response.IsSuccessStatusCode)
+ throw new InvalidOperationException($"OpenRouter-Fehler {(int)response.StatusCode}: {Truncate(json, 500)}");
+
+ return ParseResponse(json);
+ }
+
+ // ----- pure, testbare Serialisierung -----
+
+ internal static string BuildRequestBody(string model, IReadOnlyList messages, IReadOnlyList tools)
+ {
+ using var ms = new MemoryStream();
+ using (var w = new Utf8JsonWriter(ms))
+ {
+ w.WriteStartObject();
+ w.WriteString("model", model);
+
+ w.WriteStartArray("messages");
+ foreach (var m in messages)
+ {
+ w.WriteStartObject();
+ w.WriteString("role", m.Role);
+ if (m.Content != null) w.WriteString("content", m.Content);
+ else w.WriteNull("content");
+ if (m.ToolCalls is { Count: > 0 })
+ {
+ w.WriteStartArray("tool_calls");
+ foreach (var tc in m.ToolCalls)
+ {
+ w.WriteStartObject();
+ w.WriteString("id", tc.Id);
+ w.WriteString("type", "function");
+ w.WriteStartObject("function");
+ w.WriteString("name", tc.Name);
+ w.WriteString("arguments", tc.ArgumentsJson);
+ w.WriteEndObject();
+ w.WriteEndObject();
+ }
+ w.WriteEndArray();
+ }
+ if (m.ToolCallId != null) w.WriteString("tool_call_id", m.ToolCallId);
+ w.WriteEndObject();
+ }
+ w.WriteEndArray();
+
+ if (tools.Count > 0)
+ {
+ w.WriteStartArray("tools");
+ foreach (var t in tools)
+ {
+ w.WriteStartObject();
+ w.WriteString("type", "function");
+ w.WriteStartObject("function");
+ w.WriteString("name", t.Name);
+ w.WriteString("description", t.Description);
+ w.WritePropertyName("parameters");
+ using (var doc = JsonDocument.Parse(t.ParametersJsonSchema))
+ doc.RootElement.WriteTo(w);
+ w.WriteEndObject();
+ w.WriteEndObject();
+ }
+ w.WriteEndArray();
+ }
+
+ w.WriteEndObject();
+ }
+ return Encoding.UTF8.GetString(ms.ToArray());
+ }
+
+ internal static ChatResponse ParseResponse(string json)
+ {
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ var message = root.GetProperty("choices")[0].GetProperty("message");
+
+ string? content = message.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String
+ ? c.GetString() : null;
+
+ var toolCalls = new List();
+ if (message.TryGetProperty("tool_calls", out var tcs) && tcs.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var tc in tcs.EnumerateArray())
+ {
+ string id = tc.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? "" : "";
+ var fn = tc.GetProperty("function");
+ toolCalls.Add(new ToolCall(id,
+ fn.GetProperty("name").GetString() ?? "",
+ fn.TryGetProperty("arguments", out var a) ? a.GetString() ?? "{}" : "{}"));
+ }
+ }
+
+ int promptTokens = 0, completionTokens = 0;
+ if (root.TryGetProperty("usage", out var usage))
+ {
+ if (usage.TryGetProperty("prompt_tokens", out var pt)) promptTokens = pt.GetInt32();
+ if (usage.TryGetProperty("completion_tokens", out var ctk)) completionTokens = ctk.GetInt32();
+ }
+
+ return new ChatResponse { Content = content, ToolCalls = toolCalls, PromptTokens = promptTokens, CompletionTokens = completionTokens };
+ }
+
+ private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max] + "…";
+ }
+}
diff --git a/src/PolyTrader.Modules.Supervisor/Agent/SupervisorAgent.cs b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorAgent.cs
new file mode 100644
index 0000000..6d9c12b
--- /dev/null
+++ b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorAgent.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PolyTrader.Modules.Supervisor.Agent
+{
+ /// Ergebnis einer Agenten-Anfrage inkl. transparenter Tool-Aufruf-Historie.
+ public sealed class AgentResult
+ {
+ public string Answer { get; init; } = string.Empty;
+ public List<(string Tool, string Arguments, string Result)> ToolInvocations { get; init; } = new();
+ public int PromptTokens { get; init; }
+ public int CompletionTokens { get; init; }
+ }
+
+ ///
+ /// Der Analyse-Agent (S-2): Function-Calling-Loop gegen einen
+ /// mit der read-only . System-Kontext = Arbeitsanweisung +
+ /// Architektur-Dokument. Harte Iterationsgrenze gegen Endlosschleifen; jeder Tool-Aufruf wird
+ /// festgehalten (Nachvollziehbarkeit in der UI).
+ ///
+ public sealed class SupervisorAgent
+ {
+ public const int MaxIterations = 8;
+ public const string DefaultModel = "openrouter/auto";
+
+ private readonly IChatCompletionClient _chat;
+ private readonly SupervisorToolRegistry _tools;
+
+ public SupervisorAgent(IChatCompletionClient chat, SupervisorToolRegistry tools)
+ {
+ _chat = chat;
+ _tools = tools;
+ }
+
+ private static string SystemPrompt() =>
+ "Du bist der Supervisor von PolyTrader: ein Analyse-Agent für automatisierten Polymarket-Handel. " +
+ "Du bist strikt read-only – du kannst und darfst nicht handeln. Nutze die Tools, um Entscheidungsjournal, " +
+ "Order-Events, Trades und Logs abzufragen, BEVOR du Schlüsse ziehst. Zitiere konkrete Daten " +
+ "(SignalIds, Zeiten, Preise, ReasonCodes). Antworte auf Deutsch, präzise und mit klarer Schlussfolgerung.\n\n" +
+ "=== ARCHITEKTUR-KONTEXT ===\n" + ArchitectureContext.Load();
+
+ ///
+ /// Beantwortet eine Analyse-Frage. meldet Tool-Aufrufe live an die UI.
+ ///
+ public async Task AskAsync(string question, string? model = null,
+ IProgress? progress = null, CancellationToken ct = default)
+ {
+ var messages = new List
+ {
+ ChatMessage.System(SystemPrompt()),
+ ChatMessage.User(question)
+ };
+ var invocations = new List<(string, string, string)>();
+ int promptTokens = 0, completionTokens = 0;
+ string usedModel = string.IsNullOrWhiteSpace(model) ? DefaultModel : model.Trim();
+
+ for (int i = 0; i < MaxIterations; i++)
+ {
+ ct.ThrowIfCancellationRequested();
+ var response = await _chat.CompleteAsync(usedModel, messages, _tools.Tools, ct);
+ promptTokens += response.PromptTokens;
+ completionTokens += response.CompletionTokens;
+
+ if (response.ToolCalls.Count == 0)
+ {
+ return new AgentResult
+ {
+ Answer = response.Content ?? "(keine Antwort)",
+ ToolInvocations = invocations,
+ PromptTokens = promptTokens,
+ CompletionTokens = completionTokens
+ };
+ }
+
+ messages.Add(ChatMessage.Assistant(response.Content, response.ToolCalls));
+ foreach (var call in response.ToolCalls)
+ {
+ progress?.Report($"🔧 {call.Name}({call.ArgumentsJson})");
+ string result = _tools.Execute(call.Name, call.ArgumentsJson);
+ invocations.Add((call.Name, call.ArgumentsJson, result));
+ messages.Add(ChatMessage.ToolResult(call.Id, result));
+ }
+ }
+
+ return new AgentResult
+ {
+ Answer = "Abbruch: maximale Tool-Iterationen erreicht (Frage ggf. eingrenzen).",
+ ToolInvocations = invocations,
+ PromptTokens = promptTokens,
+ CompletionTokens = completionTokens
+ };
+ }
+ }
+}
diff --git a/src/PolyTrader.Modules.Supervisor/Agent/SupervisorToolRegistry.cs b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorToolRegistry.cs
new file mode 100644
index 0000000..246a52e
--- /dev/null
+++ b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorToolRegistry.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+
+namespace PolyTrader.Modules.Supervisor.Agent
+{
+ ///
+ /// Ein read-only-Analyse-Tool des Supervisors: Name, Beschreibung, JSON-Schema der Parameter und
+ /// die Ausführung. Tools LESEN ausschließlich (Journal, Events, Trades, Logs, KPIs) – es gibt
+ /// bewusst keinen Mechanismus, der handeln, canceln oder schreiben könnte.
+ ///
+ public sealed record SupervisorTool(
+ string Name,
+ string Description,
+ string ParametersJsonSchema,
+ Func Execute);
+
+ ///
+ /// Transport-agnostische Tool-Registry (S-2): heute vom In-Prozess-Agenten genutzt, später
+ /// zusätzlich über MCP-Light exponierbar (S-4). Ausführung ist fehlertolerant – eine Tool-Exception
+ /// wird als Fehlertext an das Modell zurückgegeben, nie geworfen.
+ ///
+ public sealed class SupervisorToolRegistry
+ {
+ private readonly Dictionary _tools = new(StringComparer.OrdinalIgnoreCase);
+
+ public IReadOnlyList Tools => _tools.Values.ToList();
+
+ public void Register(SupervisorTool tool) => _tools[tool.Name] = tool;
+
+ public string Execute(string name, string argumentsJson)
+ {
+ if (!_tools.TryGetValue(name, out var tool))
+ return $"FEHLER: Unbekanntes Tool '{name}'. Verfügbar: {string.Join(", ", _tools.Keys)}";
+
+ try
+ {
+ using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
+ return tool.Execute(doc.RootElement.Clone());
+ }
+ catch (JsonException ex)
+ {
+ return $"FEHLER: Ungültige Tool-Argumente (kein JSON): {ex.Message}";
+ }
+ catch (Exception ex)
+ {
+ return $"FEHLER bei Tool '{name}': {ex.Message}";
+ }
+ }
+
+ // ----- Argument-Helfer für Tool-Implementierungen -----
+
+ public static string? GetString(JsonElement args, string name) =>
+ args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
+ ? v.GetString() : null;
+
+ public static int? GetInt(JsonElement args, string name) =>
+ args.ValueKind == JsonValueKind.Object && args.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number
+ ? v.GetInt32() : (int?)null;
+ }
+}
diff --git a/src/PolyTrader.Modules.Supervisor/Agent/SupervisorTools.cs b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorTools.cs
new file mode 100644
index 0000000..8e1c9b0
--- /dev/null
+++ b/src/PolyTrader.Modules.Supervisor/Agent/SupervisorTools.cs
@@ -0,0 +1,203 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using PolyTrader.Core.Analytics;
+using PolyTrader.Core.Persistence;
+using PolyTrader.Modules.Supervisor.Services;
+using PolyTraderSharp.Services;
+
+namespace PolyTrader.Modules.Supervisor.Agent
+{
+ ///
+ /// Baut die Standard-Tool-Registry des Supervisors (S-2): read-only-Zugriffe auf
+ /// Entscheidungsjournal, Order-Events, Trade-Log, Dossiers, JSONL-Logs, KPIs und das
+ /// Architektur-Kontext-Dokument. Alle Ergebnisse als kompakte JSON-/Markdown-Strings.
+ ///
+ public static class SupervisorTools
+ {
+ private static readonly JsonSerializerOptions JsonOpts = new()
+ {
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+ };
+
+ public static SupervisorToolRegistry CreateRegistry(
+ IDecisionJournal journal,
+ IOrderEventLog orderEvents,
+ ITradeLogRepository tradeLog,
+ DossierService dossiers)
+ {
+ var reg = new SupervisorToolRegistry();
+ string logsDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
+
+ reg.Register(new SupervisorTool(
+ "query_decisions",
+ "Fragt das Entscheidungsjournal ab (JEDE Handelsentscheidung inkl. Ablehnungen mit Grund). " +
+ "Filter optional: accountId, tokenId, reason (z.B. MaxBuyPriceExceeded), decision (Executed/Rejected/Skipped/Failed), sinceHours.",
+ """{"type":"object","properties":{"accountId":{"type":"integer"},"tokenId":{"type":"string"},"reason":{"type":"string"},"decision":{"type":"string"},"sinceHours":{"type":"integer"},"limit":{"type":"integer"}}}""",
+ args =>
+ {
+ int limit = Math.Clamp(SupervisorToolRegistry.GetInt(args, "limit") ?? 100, 1, 500);
+ int? accountId = SupervisorToolRegistry.GetInt(args, "accountId");
+ string? tokenId = SupervisorToolRegistry.GetString(args, "tokenId");
+ string? reason = SupervisorToolRegistry.GetString(args, "reason");
+ string? decision = SupervisorToolRegistry.GetString(args, "decision");
+ int? sinceHours = SupervisorToolRegistry.GetInt(args, "sinceHours");
+ DateTime since = sinceHours.HasValue ? DateTime.UtcNow.AddHours(-sinceHours.Value) : DateTime.MinValue;
+
+ var rows = journal.Query(d =>
+ (accountId == null || d.AccountId == accountId) &&
+ (tokenId == null || d.TokenId == tokenId) &&
+ d.Timestamp >= since, limit * 3)
+ .Where(d => reason == null || string.Equals(d.Reason.ToString(), reason, StringComparison.OrdinalIgnoreCase))
+ .Where(d => decision == null || string.Equals(d.Decision.ToString(), decision, StringComparison.OrdinalIgnoreCase))
+ .Take(limit)
+ .Select(d => new
+ {
+ d.SignalId, ts = d.Timestamp, module = d.ModuleName, d.AccountId, d.IsDemo,
+ d.TokenId, market = d.MarketQuestion, d.Side, price = d.SignalPrice,
+ decision = d.Decision.ToString(), reason = d.Reason.ToString(), d.Message, ctx = d.ContextJson
+ });
+ return JsonSerializer.Serialize(rows, JsonOpts);
+ }));
+
+ reg.Register(new SupervisorTool(
+ "query_order_events",
+ "Fragt das Order-Lifecycle-Log ab (Platzierungen, CLOB-Antworten, Cancels, Leiter-Stufen). " +
+ "Filter optional: accountId, tokenId, signalId, sinceHours.",
+ """{"type":"object","properties":{"accountId":{"type":"integer"},"tokenId":{"type":"string"},"signalId":{"type":"string"},"sinceHours":{"type":"integer"},"limit":{"type":"integer"}}}""",
+ args =>
+ {
+ int limit = Math.Clamp(SupervisorToolRegistry.GetInt(args, "limit") ?? 100, 1, 500);
+ int? accountId = SupervisorToolRegistry.GetInt(args, "accountId");
+ string? tokenId = SupervisorToolRegistry.GetString(args, "tokenId");
+ string? signalId = SupervisorToolRegistry.GetString(args, "signalId");
+ int? sinceHours = SupervisorToolRegistry.GetInt(args, "sinceHours");
+ DateTime since = sinceHours.HasValue ? DateTime.UtcNow.AddHours(-sinceHours.Value) : DateTime.MinValue;
+
+ var rows = orderEvents.Query(e =>
+ (accountId == null || e.AccountId == accountId) &&
+ (tokenId == null || e.TokenId == tokenId) &&
+ (signalId == null || e.SignalId == signalId) &&
+ e.Timestamp >= since, limit)
+ .Select(e => new
+ {
+ e.SignalId, ts = e.Timestamp, module = e.ModuleName, e.AccountId, e.TokenId,
+ eventType = e.EventType.ToString(), e.Side, e.Price, e.AmountUsd, e.OrderType,
+ e.Response, details = e.DetailsJson
+ });
+ return JsonSerializer.Serialize(rows, JsonOpts);
+ }));
+
+ reg.Register(new SupervisorTool(
+ "query_trades",
+ "Fragt abgeschlossene Trades aus dem modulübergreifenden Trade-Log ab. " +
+ "Filter optional: accountId, moduleName, sinceDays, onlyLosers (true = nur Verlierer).",
+ """{"type":"object","properties":{"accountId":{"type":"integer"},"moduleName":{"type":"string"},"sinceDays":{"type":"integer"},"onlyLosers":{"type":"boolean"},"limit":{"type":"integer"}}}""",
+ args =>
+ {
+ int limit = Math.Clamp(SupervisorToolRegistry.GetInt(args, "limit") ?? 100, 1, 500);
+ int? accountId = SupervisorToolRegistry.GetInt(args, "accountId");
+ string? module = SupervisorToolRegistry.GetString(args, "moduleName");
+ int? sinceDays = SupervisorToolRegistry.GetInt(args, "sinceDays");
+ bool onlyLosers = args.ValueKind == JsonValueKind.Object &&
+ args.TryGetProperty("onlyLosers", out var ol) && ol.ValueKind == JsonValueKind.True;
+ DateTime since = sinceDays.HasValue ? DateTime.UtcNow.AddDays(-sinceDays.Value) : DateTime.MinValue;
+
+ var rows = tradeLog.Find(t =>
+ (accountId == null || t.AccountId == accountId) &&
+ (module == null || t.ModuleName == module) &&
+ t.ClosedAt >= since)
+ .Where(t => !onlyLosers || t.RealizedPnl < 0)
+ .OrderByDescending(t => t.ClosedAt)
+ .Take(limit)
+ .Select(t => new
+ {
+ t.SignalId, t.ModuleName, t.AccountId, t.IsDemo, t.TokenId, market = t.MarketQuestion,
+ t.Outcome, t.Side, t.EntryPrice, t.ExitPrice, t.Size, t.RealizedPnl, t.PnlPercent,
+ t.OpenedAt, t.ClosedAt, t.ExitReason
+ });
+ return JsonSerializer.Serialize(rows, JsonOpts);
+ }));
+
+ reg.Register(new SupervisorTool(
+ "get_dossier",
+ "Liefert das komplette Dossier zu einer SignalId als Markdown: Entscheidungskette, Order-Events, Trades, Log-Auszug.",
+ """{"type":"object","properties":{"signalId":{"type":"string"}},"required":["signalId"]}""",
+ args =>
+ {
+ string? signalId = SupervisorToolRegistry.GetString(args, "signalId");
+ if (string.IsNullOrWhiteSpace(signalId)) return "FEHLER: signalId fehlt.";
+ return DossierBuilder.ToMarkdown(dossiers.BuildForSignal(signalId));
+ }));
+
+ reg.Register(new SupervisorTool(
+ "read_logs",
+ "Liest die JSONL-Logdatei eines Tages (Datum yyyy-MM-dd), optional gefiltert nach level, cid (SignalId) und textFilter.",
+ """{"type":"object","properties":{"date":{"type":"string"},"level":{"type":"string"},"cid":{"type":"string"},"textFilter":{"type":"string"},"limit":{"type":"integer"}},"required":["date"]}""",
+ args =>
+ {
+ string? date = SupervisorToolRegistry.GetString(args, "date");
+ if (string.IsNullOrWhiteSpace(date)) return "FEHLER: date fehlt (yyyy-MM-dd).";
+ string path = Path.Combine(logsDir, $"{date}.jsonl");
+ if (!File.Exists(path)) return $"Keine JSONL-Datei für {date}.";
+
+ string? level = SupervisorToolRegistry.GetString(args, "level");
+ string? cid = SupervisorToolRegistry.GetString(args, "cid");
+ string? text = SupervisorToolRegistry.GetString(args, "textFilter");
+ int limit = Math.Clamp(SupervisorToolRegistry.GetInt(args, "limit") ?? 200, 1, 1000);
+
+ var lines = new List();
+ foreach (var line in File.ReadLines(path))
+ {
+ var p = LogJson.ParseLine(line);
+ if (p == null) continue;
+ if (level != null && !string.Equals(p.Level, level, StringComparison.OrdinalIgnoreCase)) continue;
+ if (cid != null && p.Cid != cid) continue;
+ if (text != null && !p.Message.Contains(text, StringComparison.OrdinalIgnoreCase)) continue;
+ lines.Add(p);
+ if (lines.Count >= limit) break;
+ }
+ return JsonSerializer.Serialize(lines, JsonOpts);
+ }));
+
+ reg.Register(new SupervisorTool(
+ "get_kpis",
+ "Berechnet Kennzahlen (Netto-PnL, Winrate, Ø-PnL, Profit-Faktor, Trade-Anzahl) über das Trade-Log. " +
+ "Filter optional: accountId, moduleName, sinceDays, isDemo.",
+ """{"type":"object","properties":{"accountId":{"type":"integer"},"moduleName":{"type":"string"},"sinceDays":{"type":"integer"},"isDemo":{"type":"boolean"}}}""",
+ args =>
+ {
+ int? accountId = SupervisorToolRegistry.GetInt(args, "accountId");
+ string? module = SupervisorToolRegistry.GetString(args, "moduleName");
+ int? sinceDays = SupervisorToolRegistry.GetInt(args, "sinceDays");
+ bool? isDemo = args.ValueKind == JsonValueKind.Object && args.TryGetProperty("isDemo", out var d) &&
+ (d.ValueKind == JsonValueKind.True || d.ValueKind == JsonValueKind.False)
+ ? d.GetBoolean() : (bool?)null;
+ DateTime since = sinceDays.HasValue ? DateTime.UtcNow.AddDays(-sinceDays.Value) : DateTime.MinValue;
+
+ var trades = tradeLog.Find(t =>
+ (accountId == null || t.AccountId == accountId) &&
+ (module == null || t.ModuleName == module) &&
+ (isDemo == null || t.IsDemo == isDemo) &&
+ t.ClosedAt >= since);
+ var k = TradeAnalytics.ComputeKpis(trades);
+ var byModule = TradeAnalytics.PnlByKey(trades, t => t.ModuleName);
+ return JsonSerializer.Serialize(new
+ {
+ k.TradeCount, k.NetPnl, k.WinRatePct, k.AvgPnlPerTrade, k.ProfitFactor,
+ byModule = byModule.Select(x => new { module = x.Key, x.Pnl, x.Count })
+ }, JsonOpts);
+ }));
+
+ reg.Register(new SupervisorTool(
+ "get_architecture_context",
+ "Liefert das kuratierte Architektur-/Verhaltensdokument von PolyTrader (wie die Software entscheidet und handelt).",
+ """{"type":"object","properties":{}}""",
+ _ => ArchitectureContext.Load()));
+
+ return reg;
+ }
+ }
+}
diff --git a/src/PolyTrader.Modules.Supervisor/Context/ArchitectureContext.md b/src/PolyTrader.Modules.Supervisor/Context/ArchitectureContext.md
new file mode 100644
index 0000000..8e37bc4
--- /dev/null
+++ b/src/PolyTrader.Modules.Supervisor/Context/ArchitectureContext.md
@@ -0,0 +1,65 @@
+# PolyTrader — Architektur- und Verhaltenskontext (für den Supervisor-Agenten)
+
+> Kuratierte, destillierte Beschreibung, WIE die Software entscheidet und handelt.
+> Bei Änderungen am Geld-Pfad mitpflegen. Stand: 2026-07.
+
+## System
+PolyTrader ist eine .NET-8-WinForms-App für automatisierten Handel auf Polymarket (Prediction Markets,
+USDC auf Polygon, Gnosis-Safe-Wallets). Aufbau: **Core** (Accounts, Markt-Cache, CLOB-/Data-API-Clients,
+Trade-Log, Entscheidungsjournal) + unabhängige **Module**: **CopyTrading**, **ResolutionFarming**,
+**Supervisor** (du — strikt read-only, du kannst NIEMALS handeln). Es gibt Live- und Demo-Accounts;
+Demo simuliert Fills realistisch (Exit-Slippage + Taker-Fees).
+
+## Datenmodell für deine Analysen
+- **core_decision_journal** (`query_decisions`): JEDE Handelsentscheidung — `Decision`
+ (Executed/Rejected/Skipped/Failed) + `Reason` (ReasonCode) + Kontext-JSON + SignalId. Auch
+ Ablehnungen! „Kein Trade" ist hier immer begründet.
+- **core_order_events** (`query_order_events`): Order-Lifecycle — Platzierungen mit CLOB-Antwort,
+ Cancels, SELL-Leiter-Stufen (LadderStart/LadderStep/FloorReplaced/DustAbort),
+ Cleanup-/Reconciliation-Cancels.
+- **core_trade_log** (`query_trades`, `get_kpis`): abgeschlossene Trades aller Module (realisierter PnL).
+- **SignalId**: verbindet Signal → Entscheidungen → Orders → Trade. `get_dossier(signalId)` liefert
+ die komplette Kette. **Beginne Einzelfall-Analysen immer mit dem Dossier.**
+- **JSONL-Logs** (`read_logs`): Freitext-Verlauf je Tag, `cid` = SignalId.
+
+## CopyTrading (Modul 1)
+Kopiert Trades beobachteter „Master-Trader" (on-chain via Alchemy-WSS erkannt, plus API-Polling).
+**BUY-Pfad** (Reihenfolge der Checks; jeder Fehlschlag = journalisierter Reject):
+Modus (Inactive/SellOnly) → ExitPending-Skip (H3: keine Zukäufe während des Ausstiegs) → MaxBuyPrice →
+PerMaster-Limit → Zeitfenster-Limits (Restlaufzeit-Buckets 6h/24h/72h/None) → Markt-Budget (PerMarket,
+Low-Balance-Bypass „6-Shares-Minimum") → verfügbares Guthaben → Polymarket-Minimum (5.5 Shares/0.10 USDC).
+Limit-Preis: HF-Master (High-Frequency) = Signalpreis + 0.005 fest; sonst prozentualer Aufschlag
+(MaxPriceDifference), gedeckelt (MaxBuyPrice, hart 0.99). Maker-Entry-Option: GTC am Signalpreis ohne
+Aufschlag (0 Fees), sonst GTD.
+**SELL-Pfad**: Spam-Blockade (<20s) → Leiter-aktiv-Skip → Ownership-Check (Position muss vom selben
+Master stammen; System-Signale TraderId==0 = Marktauflösung sind ausgenommen) → Teilverkaufs-Filter
+(Master verkauft <30% seines Bestands = Rauschen, wird ignoriert) → Tracking-Inkonsistenz/Schonfrist.
+**SELL-Eskalationsleiter** (statt Market-Dump): GTC-Limit nahe Master-Exit, ohne Fill stufenweises
+Nachpreisen (−3% relativ je Stufe; HF ~20s, sonst ~120s Intervall) bis zum **Floor**
+(Master-Exit × (1 − SellFloorPct)). Am Floor: halten + Benachrichtigung; die Floor-Order wird
+überwacht und bei Verschwinden neu platziert. Dust-Reste unter Minimum beenden die Leiter.
+**Take-Profit** (ProfitTarget, meist 9999 = deaktiviert) startet dieselbe Leiter. PreRedeem hat Vorrang.
+Fees: Taker kategorieabhängig (~Sports 0.75%, Politik 1.0%, Krypto 1.8%, Geopolitik 0), Maker 0.
+
+## ResolutionFarming (Modul 2)
+Kauft unterbewertete Favoriten (0.90–0.98) in bald auflösenden Märkten (<48h), hält bis Resolution.
+Scanner filtert (Preisband → Kategorie-Whitelist → Blacklist → Auflösungsfenster → Netto-Edge nach
+Fees ≥ MinEdge); ALLE Kandidaten inkl. Rejects stehen in rf_candidates. Risiko-Limits: MaxPerMarket,
+MaxPerCluster (korrelierte Events teilen einen Cluster-Key), MaxTotalExposure, Tages-Drossel,
+Tagesverlust-Kill-Switch. Ein verlorener 95¢-Trade vernichtet ~19 Gewinner — das Risikomodell IST
+die Strategie. Aktuell Demo (Maker-Fill 0 Fee); Live-Anbindung folgt.
+
+## Bekannte Eigenheiten / typische Fehlerbilder
+- API-Lag: Positions-API zeigt geschlossene Positionen kurz weiter → Dedup-Guards (`_processedClosures`).
+- Demo-Fills sind Näherungen (Signalpreis − halber Spread) — Demo-PnL ist leicht optimistisch bei
+ illiquiden Märkten.
+- Latenz Master-Trade → unser Fill kostet Marge (Preisdifferenz Signal vs. orderPrice im Journal-Kontext).
+- Rejects wegen TimeWindow/Budget sind NORMAL (Risikomodell) — häufe Rejects nur dann als Problem,
+ wenn sie systematisch profitable Trades verhindern (mit Marktausgang gegenprüfen).
+- „Position nicht gefunden" bei SELLs: oft wurde der BUY zuvor gefiltert (im Journal nachsehen!).
+
+## Deine Arbeitsweise
+1. Daten VOR Meinung: nutze Tools, zitiere konkrete Zahlen/IDs (SignalId, Zeiten, Preise).
+2. Einzelfall: `get_dossier` zuerst. Muster: `query_decisions`/`query_trades` mit Filtern, dann Drilldown.
+3. Unterscheide „schlechtes Signal" (Master/Markt) von „schlechter Ausführung" (Latenz/Fees/Leiter).
+4. Antworte auf Deutsch, präzise, mit klarer Schlussfolgerung und ggf. konkreten Verbesserungsvorschlägen.
diff --git a/src/PolyTrader.Modules.Supervisor/PolyTrader.Modules.Supervisor.csproj b/src/PolyTrader.Modules.Supervisor/PolyTrader.Modules.Supervisor.csproj
index a1835e4..790239d 100644
--- a/src/PolyTrader.Modules.Supervisor/PolyTrader.Modules.Supervisor.csproj
+++ b/src/PolyTrader.Modules.Supervisor/PolyTrader.Modules.Supervisor.csproj
@@ -11,6 +11,11 @@
+
+
+
+
+
net8.0-windows
enable
diff --git a/src/PolyTrader.Modules.Supervisor/SupervisorModule.cs b/src/PolyTrader.Modules.Supervisor/SupervisorModule.cs
index 9895f9f..13688a6 100644
--- a/src/PolyTrader.Modules.Supervisor/SupervisorModule.cs
+++ b/src/PolyTrader.Modules.Supervisor/SupervisorModule.cs
@@ -20,9 +20,21 @@ namespace PolyTrader.Modules.Supervisor
public void RegisterServices(IServiceCollection services, IConfiguration configuration)
{
- // S-1: reine Beschaffung/Aufbereitung. Eigene sup_-Persistenz (Berichte/Konversationen)
- // folgt mit S-2/S-3.
+ // S-1: Dossier-Beschaffung/-Aufbereitung.
services.AddSingleton();
+
+ // S-2: read-only Tool-Registry + OpenRouter-Agent. Der API-Key kommt aus
+ // POLYTRADER_OPENROUTER_KEY bzw. gitignorierter openrouter.key (getrennt vom Trading).
+ services.AddSingleton(sp => Agent.SupervisorTools.CreateRegistry(
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService()));
+ services.AddSingleton(_ =>
+ new Agent.OpenRouterClient(new System.Net.Http.HttpClient { Timeout = TimeSpan.FromMinutes(3) }));
+ services.AddSingleton();
+
+ // sup_-Persistenz (Berichte/Konversationen) + Profile folgen mit S-3.
}
public void RegisterUi(IModuleUiHost host, System.IServiceProvider services)
diff --git a/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs b/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs
index 9a0ec3f..c2611d8 100644
--- a/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs
+++ b/src/PolyTrader.Modules.Supervisor/Ui/SupervisorMainForm.cs
@@ -3,26 +3,44 @@ using System.Collections.Generic;
using System.Windows.Forms;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Core.Analytics;
+using PolyTrader.Modules.Supervisor.Agent;
using PolyTrader.Modules.Supervisor.Services;
namespace PolyTrader.Modules.Supervisor.Ui
{
///
- /// Hauptfenster des Supervisor-Moduls (S-1): Dossier-Browser — links die jüngsten Signale
- /// (aus dem Entscheidungsjournal), rechts das komplette Dossier (Markdown). Der Analyse-Chat
- /// (OpenRouter-Agent) folgt in S-2 als weiterer Tab. Code-only konstruiert (Muster RF-Modul);
- /// DB-Zugriffe defensiv, damit die UI auch bei leerer/nicht erreichbarer DB bedienbar bleibt.
+ /// Hauptfenster des Supervisor-Moduls: Tab „Analyse" (Chat mit dem read-only-Agenten,
+ /// Tool-Aufrufe transparent im Verlauf) und Tab „Dossiers" (Signal-Browser mit Markdown-Dossier).
+ /// Code-only konstruiert (Muster RF-Modul); DB-/API-Zugriffe defensiv.
///
public sealed class SupervisorMainForm : Form
{
private DossierService? _dossiers;
+ private SupervisorAgent? _agent;
- private readonly ToolStrip _toolStrip = new();
+ // ----- Tab Analyse (Chat) -----
+ private readonly ToolStrip _chatStrip = new();
+ private readonly ToolStripLabel _lblModel = new() { Text = "Modell:" };
+ private readonly ToolStripTextBox _tbModel = new() { AutoSize = false, Width = 220, Text = SupervisorAgent.DefaultModel };
+ private readonly ToolStripButton _btnClearChat = new() { Text = "Verlauf leeren", DisplayStyle = ToolStripItemDisplayStyle.Text };
+ private readonly RichTextBox _chatLog = new()
+ {
+ Dock = DockStyle.Fill, ReadOnly = true, BackColor = System.Drawing.Color.White,
+ Font = new System.Drawing.Font("Segoe UI", 9.5f)
+ };
+ private readonly TextBox _chatInput = new()
+ {
+ Dock = DockStyle.Fill, Multiline = true, Height = 54,
+ PlaceholderText = "Analyse-Frage stellen … (Strg+Enter zum Senden)"
+ };
+ private readonly Button _btnSend = new() { Text = "Senden", Dock = DockStyle.Right, Width = 110 };
+
+ // ----- Tab Dossiers (Browser) -----
+ private readonly ToolStrip _dossierStrip = new();
private readonly ToolStripButton _btnRefresh = new() { Text = "Aktualisieren", DisplayStyle = ToolStripItemDisplayStyle.Text };
private readonly ToolStripLabel _lblSearch = new() { Text = "SignalId:" };
private readonly ToolStripTextBox _tbSignalId = new() { AutoSize = false, Width = 220 };
private readonly ToolStripButton _btnOpen = new() { Text = "Dossier öffnen", DisplayStyle = ToolStripItemDisplayStyle.Text };
-
private readonly SplitContainer _split = new() { Dock = DockStyle.Fill, SplitterDistance = 420 };
private readonly DataGridView _grid = new()
{
@@ -35,24 +53,49 @@ namespace PolyTrader.Modules.Supervisor.Ui
Dock = DockStyle.Fill, Multiline = true, ReadOnly = true, ScrollBars = ScrollBars.Both,
Font = new System.Drawing.Font("Consolas", 9.5f), WordWrap = false
};
+
private readonly Label _status = new() { Dock = DockStyle.Bottom, Height = 22, Padding = new Padding(6, 2, 6, 2), Text = "" };
public SupervisorMainForm()
{
Text = "Supervisor";
Width = 1250;
- Height = 700;
+ Height = 720;
StartPosition = FormStartPosition.CenterScreen;
- _toolStrip.Items.AddRange(new ToolStripItem[] { _btnRefresh, new ToolStripSeparator(), _lblSearch, _tbSignalId, _btnOpen });
+ var tabs = new TabControl { Dock = DockStyle.Fill };
+
+ // --- Tab Analyse ---
+ var tabAnalyse = new TabPage("Analyse");
+ _chatStrip.Items.AddRange(new ToolStripItem[] { _lblModel, _tbModel, new ToolStripSeparator(), _btnClearChat });
+ var inputPanel = new Panel { Dock = DockStyle.Bottom, Height = 60, Padding = new Padding(4) };
+ inputPanel.Controls.Add(_chatInput);
+ inputPanel.Controls.Add(_btnSend);
+ tabAnalyse.Controls.Add(_chatLog);
+ tabAnalyse.Controls.Add(inputPanel);
+ tabAnalyse.Controls.Add(_chatStrip);
+ _chatStrip.Dock = DockStyle.Top;
+
+ // --- Tab Dossiers ---
+ var tabDossiers = new TabPage("Dossiers");
+ _dossierStrip.Items.AddRange(new ToolStripItem[] { _btnRefresh, new ToolStripSeparator(), _lblSearch, _tbSignalId, _btnOpen });
_split.Panel1.Controls.Add(_grid);
_split.Panel2.Controls.Add(_dossierText);
+ tabDossiers.Controls.Add(_split);
+ tabDossiers.Controls.Add(_dossierStrip);
+ _dossierStrip.Dock = DockStyle.Top;
- Controls.Add(_split);
- Controls.Add(_toolStrip);
+ tabs.TabPages.AddRange(new[] { tabAnalyse, tabDossiers });
+ Controls.Add(tabs);
Controls.Add(_status);
- _toolStrip.Dock = DockStyle.Top;
+ // Verhalten
+ _btnSend.Click += async (_, _) => await SendQuestionAsync();
+ _chatInput.KeyDown += async (_, e) =>
+ {
+ if (e.Control && e.KeyCode == Keys.Enter) { e.SuppressKeyPress = true; await SendQuestionAsync(); }
+ };
+ _btnClearChat.Click += (_, _) => _chatLog.Clear();
_btnRefresh.Click += (_, _) => LoadSignals();
_btnOpen.Click += (_, _) => OpenDossier(_tbSignalId.Text.Trim());
_grid.SelectionChanged += (_, _) => OpenSelected();
@@ -61,9 +104,55 @@ namespace PolyTrader.Modules.Supervisor.Ui
public void Initialize(IServiceProvider services)
{
_dossiers = services.GetRequiredService();
+ _agent = services.GetRequiredService();
LoadSignals();
+ AppendChat("System", "Supervisor bereit. Read-only-Analyse über Entscheidungsjournal, Order-Events, Trades und Logs. " +
+ "API-Key: POLYTRADER_OPENROUTER_KEY oder Datei openrouter.key.", System.Drawing.Color.Gray);
}
+ // ===== Analyse-Chat =====
+
+ private async System.Threading.Tasks.Task SendQuestionAsync()
+ {
+ if (_agent == null) return;
+ string question = _chatInput.Text.Trim();
+ if (question.Length == 0) return;
+
+ _chatInput.Text = "";
+ _btnSend.Enabled = false;
+ AppendChat("Du", question, System.Drawing.Color.DarkBlue);
+ _status.Text = "Analyse läuft …";
+
+ var progress = new Progress(msg => AppendChat("Tool", msg, System.Drawing.Color.DarkGoldenrod));
+ try
+ {
+ var result = await System.Threading.Tasks.Task.Run(() =>
+ _agent.AskAsync(question, _tbModel.Text, progress));
+ AppendChat("Supervisor", result.Answer, System.Drawing.Color.Black);
+ _status.Text = $"Fertig. {result.ToolInvocations.Count} Tool-Aufruf(e), ~{result.PromptTokens + result.CompletionTokens} Tokens.";
+ }
+ catch (Exception ex)
+ {
+ AppendChat("Fehler", ex.Message, System.Drawing.Color.Firebrick);
+ _status.Text = "Fehler bei der Analyse.";
+ }
+ finally
+ {
+ _btnSend.Enabled = true;
+ }
+ }
+
+ private void AppendChat(string who, string text, System.Drawing.Color color)
+ {
+ if (InvokeRequired) { BeginInvoke(() => AppendChat(who, text, color)); return; }
+ _chatLog.SelectionStart = _chatLog.TextLength;
+ _chatLog.SelectionColor = color;
+ _chatLog.AppendText($"[{DateTime.Now:HH:mm:ss}] {who}: {text}{Environment.NewLine}{Environment.NewLine}");
+ _chatLog.ScrollToCaret();
+ }
+
+ // ===== Dossier-Browser =====
+
private void LoadSignals()
{
if (_dossiers == null) return;
diff --git a/tests/PolyTrader.Tests/SupervisorAgentTests.cs b/tests/PolyTrader.Tests/SupervisorAgentTests.cs
new file mode 100644
index 0000000..7ac5339
--- /dev/null
+++ b/tests/PolyTrader.Tests/SupervisorAgentTests.cs
@@ -0,0 +1,158 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using PolyTrader.Modules.Supervisor.Agent;
+using Xunit;
+
+namespace PolyTrader.Tests
+{
+ ///
+ /// Sicherheitsnetz für den S-2-Agenten: Tool-Registry (Ausführung, Fehler, Unbekanntes),
+ /// Function-Calling-Loop (Tool-Ergebnisse fließen zurück, Iterationsgrenze) und die
+ /// OpenRouter-Serialisierung (Request-Body pur, Response-Parsing).
+ ///
+ public class SupervisorAgentTests
+ {
+ // ----- Registry -----
+
+ private static SupervisorToolRegistry RegistryWithEcho()
+ {
+ var reg = new SupervisorToolRegistry();
+ reg.Register(new SupervisorTool("echo", "Echo",
+ """{"type":"object","properties":{"text":{"type":"string"}}}""",
+ args => "ECHO:" + (SupervisorToolRegistry.GetString(args, "text") ?? "")));
+ reg.Register(new SupervisorTool("boom", "wirft",
+ """{"type":"object","properties":{}}""",
+ _ => throw new InvalidOperationException("kaputt")));
+ return reg;
+ }
+
+ [Fact]
+ public void Registry_executes_tool_with_args()
+ {
+ Assert.Equal("ECHO:hallo", RegistryWithEcho().Execute("echo", """{"text":"hallo"}"""));
+ }
+
+ [Fact]
+ public void Registry_unknown_tool_and_broken_args_return_error_strings()
+ {
+ var reg = RegistryWithEcho();
+ Assert.StartsWith("FEHLER: Unbekanntes Tool", reg.Execute("gibtsnicht", "{}"));
+ Assert.StartsWith("FEHLER: Ungültige Tool-Argumente", reg.Execute("echo", "{kein json"));
+ Assert.StartsWith("FEHLER bei Tool 'boom'", reg.Execute("boom", "{}")); // Exception -> Text, wirft nie
+ }
+
+ // ----- Agent-Loop -----
+
+ private sealed class ScriptedChatClient : IChatCompletionClient
+ {
+ private readonly Queue _script;
+ public List> Requests { get; } = new();
+
+ public ScriptedChatClient(params ChatResponse[] script) => _script = new Queue(script);
+
+ public Task CompleteAsync(string model, IReadOnlyList messages,
+ IReadOnlyList tools, CancellationToken ct)
+ {
+ Requests.Add(new List(messages));
+ return Task.FromResult(_script.Count > 0 ? _script.Dequeue() : new ChatResponse { Content = "leer" });
+ }
+ }
+
+ [Fact]
+ public async Task Agent_executes_tool_calls_and_feeds_results_back()
+ {
+ var chat = new ScriptedChatClient(
+ new ChatResponse { ToolCalls = { new ToolCall("c1", "echo", """{"text":"daten"}""") } },
+ new ChatResponse { Content = "Fertige Analyse." });
+ var agent = new SupervisorAgent(chat, RegistryWithEcho());
+
+ var result = await agent.AskAsync("Warum X?");
+
+ Assert.Equal("Fertige Analyse.", result.Answer);
+ Assert.Single(result.ToolInvocations);
+ Assert.Equal(("echo", """{"text":"daten"}""", "ECHO:daten"), result.ToolInvocations[0]);
+
+ // Zweiter Request enthält Assistant-ToolCall + Tool-Ergebnis.
+ var second = chat.Requests[1];
+ Assert.Contains(second, m => m.Role == "assistant" && m.ToolCalls is { Count: 1 });
+ Assert.Contains(second, m => m.Role == "tool" && m.ToolCallId == "c1" && m.Content == "ECHO:daten");
+ // System-Prompt enthält den Architektur-Kontext.
+ Assert.Contains("ARCHITEKTUR-KONTEXT", second[0].Content);
+ }
+
+ [Fact]
+ public async Task Agent_stops_at_max_iterations()
+ {
+ // Modell fordert ENDLOS Tools an -> Agent bricht kontrolliert ab.
+ var endless = Enumerable.Range(0, SupervisorAgent.MaxIterations + 2)
+ .Select(i => new ChatResponse { ToolCalls = { new ToolCall($"c{i}", "echo", "{}") } })
+ .ToArray();
+ var agent = new SupervisorAgent(new ScriptedChatClient(endless), RegistryWithEcho());
+
+ var result = await agent.AskAsync("loop");
+
+ Assert.Contains("maximale Tool-Iterationen", result.Answer);
+ Assert.Equal(SupervisorAgent.MaxIterations, result.ToolInvocations.Count);
+ }
+
+ // ----- OpenRouter-Serialisierung (pur) -----
+
+ [Fact]
+ public void BuildRequestBody_produces_openai_compatible_json()
+ {
+ var messages = new List
+ {
+ ChatMessage.System("sys"),
+ ChatMessage.User("frage"),
+ ChatMessage.Assistant(null, new List { new("c1", "echo", """{"text":"x"}""") }),
+ ChatMessage.ToolResult("c1", "ergebnis")
+ };
+ var tools = new List
+ {
+ new("echo", "Echo-Tool", """{"type":"object","properties":{"text":{"type":"string"}}}""", _ => "")
+ };
+
+ string body = OpenRouterClient.BuildRequestBody("openrouter/auto", messages, tools);
+ using var doc = JsonDocument.Parse(body); // valides JSON
+ var root = doc.RootElement;
+
+ Assert.Equal("openrouter/auto", root.GetProperty("model").GetString());
+ var msgs = root.GetProperty("messages");
+ Assert.Equal(4, msgs.GetArrayLength());
+ Assert.Equal("function", msgs[2].GetProperty("tool_calls")[0].GetProperty("type").GetString());
+ Assert.Equal("echo", msgs[2].GetProperty("tool_calls")[0].GetProperty("function").GetProperty("name").GetString());
+ Assert.Equal("c1", msgs[3].GetProperty("tool_call_id").GetString());
+ Assert.Equal("echo", root.GetProperty("tools")[0].GetProperty("function").GetProperty("name").GetString());
+ }
+
+ [Fact]
+ public void ParseResponse_reads_content_toolcalls_and_usage()
+ {
+ const string json = """
+ {"choices":[{"message":{"content":null,"tool_calls":[
+ {"id":"call_1","type":"function","function":{"name":"query_trades","arguments":"{\"limit\":5}"}}]}}],
+ "usage":{"prompt_tokens":120,"completion_tokens":30}}
+ """;
+ var r = OpenRouterClient.ParseResponse(json);
+
+ Assert.Null(r.Content);
+ Assert.Single(r.ToolCalls);
+ Assert.Equal("query_trades", r.ToolCalls[0].Name);
+ Assert.Equal("""{"limit":5}""", r.ToolCalls[0].ArgumentsJson);
+ Assert.Equal(120, r.PromptTokens);
+ Assert.Equal(30, r.CompletionTokens);
+ }
+
+ [Fact]
+ public void ArchitectureContext_is_embedded_and_loads()
+ {
+ string ctx = ArchitectureContext.Load();
+ Assert.Contains("PolyTrader", ctx);
+ Assert.Contains("read-only", ctx);
+ }
+ }
+}