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 { /// Sicherheitsnetz für S-3: Counterfactual-Job (Rejects vs. Marktausgang) und Tagesbericht. 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 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(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 CompleteAsync(string model, IReadOnlyList messages, IReadOnlyList 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(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(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)); } } }