Supervisor S-3-Rest + RF-Designer-Konvertierung: Counterfactual, Tagesbericht, UI

RF-UI designerfaehig (Richards Vorgabe, letzter code-only-Altbestand):
- ResolutionFarmingMainForm auf partial + .Designer.cs umgestellt (4 Tabs Kandidaten/
  Positionen/Historie/Settings, alle Controls im Designer; Verhalten unveraendert).

S-3 Counterfactual ('Was waere aus abgelehnten BUYs geworden?'):
- CounterfactualJob (alle 6h, API-gedrosselt): nimmt Rejected-BUY-Entscheidungen mit
  abgelaufenem MarketEndDate aus dem Journal, prueft die Marktaufloesung
  (ICounterfactualResolutionSource; live = Adapter um CheckMarketResolutionAsync) und
  speichert IsWinner + hypothetischen PnL/Share (CounterfactualMath, pur) nach
  sup_counterfactuals (unique je DecisionId -> idempotent). Migration generiert+angewendet.
- Agent-Tool query_counterfactuals + UI-Tab 'Counterfactual' (via Designer).

S-3 Threema-Tagesbericht:
- DailyReportService: taeglich zur konfigurierten Stunde (OPT-IN via
  POLYTRADER_SUPERVISOR_DAILY=0-23) laesst der Agent einen 24h-Kurzbericht erstellen
  (KPIs je Modul, Fehler, Reject-Haeufungen), sendet via Threema und legt ihn als
  sup_report ab. Ohne OpenRouter-Key: stiller Skip mit Log.

Tests: +7 (CounterfactualMath, Job aufgeloest/unaufgeloest/idempotent, Tagesbericht
mit/ohne Key). Build 0 Fehler, 367 Tests gruen, --smoke-ui alle 5 Views gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-18 11:14:45 +02:00
co-authored by Claude Opus 4.8
parent b7b2a141e3
commit c7668170a4
14 changed files with 1252 additions and 95 deletions
+153
View File
@@ -0,0 +1,153 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using PolyTrader.Modules.Supervisor.Agent;
using PolyTrader.Modules.Supervisor.Counterfactual;
using PolyTrader.Modules.Supervisor.Persistence;
using PolyTrader.Tests.Fakes;
using PolyTrader.Tests.TestSupport;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>Sicherheitsnetz für S-3: Counterfactual-Job (Rejects vs. Marktausgang) und Tagesbericht.</summary>
public class SupervisorS3Tests
{
// ----- CounterfactualMath (pur) -----
[Theory]
[InlineData(0.95, true, 0.05)] // Gewinner: 1 - 0.95
[InlineData(0.95, false, -0.95)] // Verlierer: -Preis
[InlineData(0.40, true, 0.60)]
public void PnlPerShare_winner_and_loser(double price, bool winner, double expected)
{
Assert.Equal((decimal)expected, CounterfactualMath.PnlPerShare((decimal)price, winner));
}
// ----- CounterfactualJob -----
private sealed class FakeResolutionSource : ICounterfactualResolutionSource
{
public Dictionary<string, (bool resolved, bool winner)> ByToken { get; } = new();
public int Calls { get; private set; }
public Task<(bool isResolved, bool isWinner)> CheckAsync(string slug, string tokenId, CancellationToken ct)
{
Calls++;
return Task.FromResult(ByToken.TryGetValue(tokenId, out var r) ? r : (false, false));
}
}
private static DecisionRecord RejectedBuy(long id, string token, decimal price, DateTime endDate) => new()
{
Id = id, SignalId = "sig-" + id, ModuleName = "CopyTrading", AccountId = 1,
TokenId = token, MarketSlug = "slug-" + token, MarketQuestion = "Q-" + token, Side = "BUY",
SignalPrice = price, MarketEndDate = endDate,
Decision = TradeDecision.Rejected, Reason = DecisionReason.MaxBuyPriceExceeded
};
private static (CounterfactualJob job, FakeDecisionJournal journal, EfSupervisorCounterfactualRepository repo, FakeResolutionSource res) BuildJob()
{
var journal = new FakeDecisionJournal();
var factory = new InMemoryContextFactory<SupervisorDbContext>(o => new SupervisorDbContext(o));
var repo = new EfSupervisorCounterfactualRepository(factory);
var res = new FakeResolutionSource();
var job = new CounterfactualJob(journal, repo, res, new TerminalLogger());
return (job, journal, repo, res);
}
[Fact]
public async Task Job_evaluates_resolved_rejects_and_skips_unresolved()
{
var (job, journal, repo, res) = BuildJob();
var past = DateTime.UtcNow.AddDays(-1);
journal.Written.Add(RejectedBuy(1, "a", 0.95m, past)); // aufgelöst: Gewinner
journal.Written.Add(RejectedBuy(2, "b", 0.60m, past)); // aufgelöst: Verlierer
journal.Written.Add(RejectedBuy(3, "c", 0.90m, past)); // noch NICHT aufgelöst
journal.Written.Add(RejectedBuy(4, "d", 0.90m, DateTime.UtcNow.AddDays(2))); // Markt läuft noch
res.ByToken["a"] = (true, true);
res.ByToken["b"] = (true, false);
int added = await job.CheckOnceAsync(CancellationToken.None);
Assert.Equal(2, added);
var rows = repo.GetRecent(10);
Assert.Equal(2, rows.Count);
var a = rows.Single(r => r.TokenId == "a");
Assert.True(a.IsWinner);
Assert.Equal(0.05m, a.HypotheticalPnlPerShare);
Assert.Equal("MaxBuyPriceExceeded", a.Reason);
var b = rows.Single(r => r.TokenId == "b");
Assert.False(b.IsWinner);
Assert.Equal(-0.60m, b.HypotheticalPnlPerShare);
}
[Fact]
public async Task Job_is_idempotent_per_decision()
{
var (job, journal, repo, res) = BuildJob();
journal.Written.Add(RejectedBuy(1, "a", 0.95m, DateTime.UtcNow.AddDays(-1)));
res.ByToken["a"] = (true, true);
Assert.Equal(1, await job.CheckOnceAsync(CancellationToken.None));
Assert.Equal(0, await job.CheckOnceAsync(CancellationToken.None)); // schon ausgewertet
Assert.Single(repo.GetRecent(10));
}
// ----- DailyReportService -----
private sealed class OneShotChatClient : IChatCompletionClient
{
public Task<ChatResponse> CompleteAsync(string model, IReadOnlyList<ChatMessage> messages,
IReadOnlyList<SupervisorTool> tools, CancellationToken ct)
=> Task.FromResult(new ChatResponse { Content = "Alles im grünen Bereich." });
}
[Fact]
public async Task DailyReport_runs_agent_saves_report_and_sends()
{
Environment.SetEnvironmentVariable("POLYTRADER_OPENROUTER_KEY", "test-key");
try
{
var factory = new InMemoryContextFactory<SupervisorDbContext>(o => new SupervisorDbContext(o));
var reports = new EfSupervisorReportRepository(factory);
var agent = new SupervisorAgent(new OneShotChatClient(), new SupervisorToolRegistry());
string? sent = null;
var svc = new PolyTrader.Modules.Supervisor.Services.DailyReportService(
agent, reports, msg => { sent = msg; return Task.CompletedTask; }, new TerminalLogger());
await svc.RunOnceAsync(CancellationToken.None);
Assert.NotNull(sent);
Assert.Contains("Alles im grünen Bereich", sent);
var saved = reports.GetRecent(5);
Assert.Single(saved);
Assert.Equal("Tagesbericht", saved[0].Profile);
}
finally
{
Environment.SetEnvironmentVariable("POLYTRADER_OPENROUTER_KEY", null);
}
}
[Fact]
public async Task DailyReport_skips_without_api_key()
{
Environment.SetEnvironmentVariable("POLYTRADER_OPENROUTER_KEY", null);
var factory = new InMemoryContextFactory<SupervisorDbContext>(o => new SupervisorDbContext(o));
var reports = new EfSupervisorReportRepository(factory);
var agent = new SupervisorAgent(new OneShotChatClient(), new SupervisorToolRegistry());
bool sent = false;
var svc = new PolyTrader.Modules.Supervisor.Services.DailyReportService(
agent, reports, _ => { sent = true; return Task.CompletedTask; }, new TerminalLogger());
await svc.RunOnceAsync(CancellationToken.None);
Assert.False(sent);
Assert.Empty(reports.GetRecent(5));
}
}
}