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:
co-authored by
Claude Opus 4.8
parent
b7b2a141e3
commit
c7668170a4
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user