diff --git a/src/PolyTrader.Modules.ResolutionFarming/ResolutionFarmingModule.cs b/src/PolyTrader.Modules.ResolutionFarming/ResolutionFarmingModule.cs index bf87862..172eafc 100644 --- a/src/PolyTrader.Modules.ResolutionFarming/ResolutionFarmingModule.cs +++ b/src/PolyTrader.Modules.ResolutionFarming/ResolutionFarmingModule.cs @@ -35,7 +35,14 @@ namespace PolyTrader.Modules.ResolutionFarming services.AddSingleton(); services.AddSingleton(); - // Scanner-/Monitor-Jobs, Execution und UI folgen in den nächsten Slices. + // Read-only-Scanner (Phase RF-1). Marktquelle vorerst NullFarmingMarketSource: das Modul + // läuft ohne Live-Gamma/CLOB-Anbindung (die im Zielland registriert wird) und produziert + // dann korrekt keine Kandidaten. + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + + // Execution, Resolution-Monitor, Auto-Redeem und UI folgen in den nächsten Slices. } public void RegisterUi(IModuleUiHost host, System.IServiceProvider services) diff --git a/src/PolyTrader.Modules.ResolutionFarming/Services/IFarmingMarketSource.cs b/src/PolyTrader.Modules.ResolutionFarming/Services/IFarmingMarketSource.cs new file mode 100644 index 0000000..d329ba4 --- /dev/null +++ b/src/PolyTrader.Modules.ResolutionFarming/Services/IFarmingMarketSource.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using PolyTrader.Modules.ResolutionFarming.Models; + +namespace PolyTrader.Modules.ResolutionFarming.Services +{ + /// + /// Quelle der zu bewertenden Märkte für den Scanner. Trennt die (live-/API-gebundene) Beschaffung + /// der Marktdaten von der reinen Bewertungs-Pipeline, damit die Scanner-Orchestrierung ohne echte + /// Gamma-/CLOB-API testbar ist. Die Live-Implementierung (Gamma-API endDate-Fenster + CLOB-Book + /// für den Ask) ist Zielland-Arbeit. + /// + public interface IFarmingMarketSource + { + /// + /// Liefert Favoriten-Ausgänge von Märkten, die innerhalb des Settings-Fensters auflösen. + /// Vorfilterung (endDate, nicht closed) darf hier passieren; die fachliche Filterung macht der Scanner. + /// + Task> GetNearResolutionFavoritesAsync(RfSettings settings, CancellationToken ct); + } +} diff --git a/src/PolyTrader.Modules.ResolutionFarming/Services/MarketScannerService.cs b/src/PolyTrader.Modules.ResolutionFarming/Services/MarketScannerService.cs new file mode 100644 index 0000000..d93ab04 --- /dev/null +++ b/src/PolyTrader.Modules.ResolutionFarming/Services/MarketScannerService.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using PolyTrader.Core.Trading; +using PolyTrader.Modules.ResolutionFarming.Logic; +using PolyTrader.Modules.ResolutionFarming.Models; +using PolyTrader.Modules.ResolutionFarming.Persistence; +using PolyTraderSharp.Services; + +namespace PolyTrader.Modules.ResolutionFarming.Services +{ + /// + /// Read-only-Scanner (Phase RF-1): holt bald auflösende Favoriten von einer + /// , bewertet sie mit der puren Filterkette + Risk-Engine und + /// schreibt JEDEN Kandidaten (akzeptiert wie abgelehnt inkl. Grund) nach rf_candidates – Basis + /// für die spätere datenbasierte Kalibrierung. Platziert KEINE Orders. + /// + /// Die eigentliche Bewertung liegt in (pur/statisch, voll unit-getestet); + /// der Service umhüllt sie nur mit Beschaffung, Persistenz und Zeitsteuerung. + /// + public class MarketScannerService : BackgroundService + { + private static readonly TimeSpan ScanInterval = TimeSpan.FromMinutes(12); + + private readonly IFarmingMarketSource _source; + private readonly IRfCandidateRepository _candidateRepo; + private readonly IRfSettingsRepository _settingsRepo; + private readonly TerminalLogger _logger; + + public MarketScannerService( + IFarmingMarketSource source, + IRfCandidateRepository candidateRepo, + IRfSettingsRepository settingsRepo, + TerminalLogger logger) + { + _source = source; + _candidateRepo = candidateRepo; + _settingsRepo = settingsRepo; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); // nach den Core-Jobs anlaufen + _logger.Info("ResolutionFarming-Scanner gestartet."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + foreach (var settings in _settingsRepo.GetAll()) + { + if (stoppingToken.IsCancellationRequested) break; + var candidates = await ScanAccountAsync(settings.AccountId, settings, stoppingToken); + int accepted = candidates.FindAll(c => c.Accepted).Count; + _logger.Info($"🔎 [RF-Scanner] Konto {settings.AccountId}: {candidates.Count} Märkte geprüft, {accepted} Kandidaten."); + } + } + catch (OperationCanceledException) { break; } + catch (Exception ex) + { + _logger.Error($"RF-Scanner Fehler: {ex.Message}"); + } + + await Task.Delay(ScanInterval, stoppingToken); + } + } + + /// Scannt und persistiert die Kandidaten eines Accounts. Liefert die bewerteten Kandidaten. + internal async Task> ScanAccountAsync(int accountId, RfSettings settings, CancellationToken ct) + { + var markets = await _source.GetNearResolutionFavoritesAsync(settings, ct); + var result = new List(markets.Count); + foreach (var m in markets) + { + var candidate = Evaluate(accountId, m, settings); + _candidateRepo.Insert(candidate); + result.Add(candidate); + } + return result; + } + + /// + /// Reine Bewertung eines Marktes gegen die Settings. Reject-Grund = ERSTER fehlschlagender + /// Filter (Preisband → Kategorie → Blacklist → Auflösungsfenster → Netto-Edge). Netto-Edge und + /// Cluster-Key werden immer berechnet (auch bei Ablehnung), für die Kalibrierung. + /// + internal static RfCandidate Evaluate(int accountId, ScannedMarket m, RfSettings settings) + { + int feeBps = FeeModel.ResolveBps(m.FeeBps, m.Category); + decimal netEdgePct = FarmingRiskEngine.NetEdgePct(m.Ask, feeBps); + + var c = new RfCandidate + { + ScannedAt = DateTime.UtcNow, + AccountId = accountId, + TokenId = m.TokenId, + MarketSlug = m.MarketSlug, + EventSlug = m.EventSlug, + MarketQuestion = m.MarketQuestion, + Outcome = m.Outcome, + Category = m.Category, + ClusterKey = FarmingScanner.ClusterKey(m.EventSlug, m.MarketSlug), + Ask = m.Ask, + FeeBps = feeBps, + NetEdgePct = netEdgePct, + Score = FarmingScanner.Score(netEdgePct, m.HoursToResolution), + EndDate = m.EndDate + }; + + if (!FarmingScanner.InPriceBand(m.Ask, settings.MinPrice, settings.MaxPrice)) + return Reject(c, $"Preis {m.Ask:F3} außerhalb Band [{settings.MinPrice:F2}, {settings.MaxPrice:F2}]"); + if (!FarmingScanner.CategoryAllowed(m.Category, settings.CategoryWhitelistCsv)) + return Reject(c, $"Kategorie '{m.Category}' nicht auf Whitelist"); + if (FarmingScanner.IsBlacklisted(m.MarketSlug, m.Tags, settings.BlacklistCsv)) + return Reject(c, "Blacklist"); + if (m.HoursToResolution > settings.MaxHoursToResolution) + return Reject(c, $"Auflösung erst in {m.HoursToResolution:F0}h > {settings.MaxHoursToResolution}h"); + if (!FarmingRiskEngine.HasEdge(m.Ask, feeBps, settings.MinEdgePct)) + return Reject(c, $"Netto-Edge {netEdgePct:F2}% < Min {settings.MinEdgePct:F2}%"); + + c.Accepted = true; + return c; + } + + private static RfCandidate Reject(RfCandidate c, string reason) + { + c.Accepted = false; + c.RejectReason = reason; + return c; + } + } + + /// + /// Platzhalter-Marktquelle (liefert nichts). Hält den Scanner lauffähig, bis die Live-Anbindung + /// (Gamma-API endDate-Fenster + CLOB-Book für den Ask) im Zielland implementiert und registriert + /// wird. So läuft das Modul ohne Live-API, produziert aber (korrekt) keine Kandidaten. + /// + public sealed class NullFarmingMarketSource : IFarmingMarketSource + { + public Task> GetNearResolutionFavoritesAsync(RfSettings settings, CancellationToken ct) + => Task.FromResult>(Array.Empty()); + } +} diff --git a/src/PolyTrader.Modules.ResolutionFarming/Services/ScannedMarket.cs b/src/PolyTrader.Modules.ResolutionFarming/Services/ScannedMarket.cs new file mode 100644 index 0000000..9b43bbc --- /dev/null +++ b/src/PolyTrader.Modules.ResolutionFarming/Services/ScannedMarket.cs @@ -0,0 +1,31 @@ +using System; + +namespace PolyTrader.Modules.ResolutionFarming.Services +{ + /// + /// Roh-Eingabe des Scanners für einen einzelnen Favoriten-Ausgang eines bald auflösenden Marktes. + /// Wird von einer geliefert (Live: Gamma-API + CLOB-Book; im + /// Test: Fake). Die Bewertung/Filterung passiert rein in FarmingScanner/FarmingRiskEngine. + /// + public sealed class ScannedMarket + { + public string TokenId { get; set; } = string.Empty; + public string MarketSlug { get; set; } = string.Empty; + public string EventSlug { get; set; } = string.Empty; + public string MarketQuestion { get; set; } = string.Empty; + public string Outcome { get; set; } = string.Empty; + public string Category { get; set; } = string.Empty; + public string Tags { get; set; } = string.Empty; + + /// Bester Ask des Favoriten (0–1). + public decimal Ask { get; set; } + + /// Taker-Fee-Satz (bps) laut Markt-Objekt; 0 = unbekannt → Kategorie-Fallback. + public int FeeBps { get; set; } + + public DateTime? EndDate { get; set; } + + /// Stunden bis zur Auflösung (aus EndDate); für Score/Filter. + public double HoursToResolution { get; set; } + } +} diff --git a/tests/PolyTrader.Tests/MarketScannerServiceTests.cs b/tests/PolyTrader.Tests/MarketScannerServiceTests.cs new file mode 100644 index 0000000..a3f0dd8 --- /dev/null +++ b/tests/PolyTrader.Tests/MarketScannerServiceTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using PolyTrader.Modules.ResolutionFarming.Models; +using PolyTrader.Modules.ResolutionFarming.Persistence.Ef; +using PolyTrader.Modules.ResolutionFarming.Services; +using PolyTrader.Tests.TestSupport; +using PolyTraderSharp.Services; +using Xunit; + +namespace PolyTrader.Tests +{ + /// + /// Tests der Scanner-Bewertung (pure Filterkette inkl. Reject-Gründe) und der Orchestrierung + /// (Marktquelle → Bewertung → Persistenz) über eine Fake-Marktquelle + reales InMemory-Repo. + /// + public class MarketScannerServiceTests + { + private static RfSettings DefaultSettings() => new() + { + AccountId = 1, MinPrice = 0.90m, MaxPrice = 0.98m, MaxHoursToResolution = 48, + CategoryWhitelistCsv = "Sports,Politics,Geopolitics", BlacklistCsv = "", MinEdgePct = 1.5m + }; + + private static ScannedMarket Market(decimal ask, string category = "Sports", double hours = 24, + string slug = "match-1", string tags = "", int feeBps = 0) => new() + { + TokenId = "tok", MarketSlug = slug, EventSlug = "event-1", MarketQuestion = "Frage?", + Outcome = "Yes", Category = category, Tags = tags, Ask = ask, FeeBps = feeBps, + HoursToResolution = hours, EndDate = DateTime.UtcNow.AddHours(hours) + }; + + // ----- Evaluate (pure) ----- + + [Fact] + public void Evaluate_accepts_a_good_favorite() + { + var c = MarketScannerService.Evaluate(1, Market(0.95m), DefaultSettings()); + Assert.True(c.Accepted); + Assert.Equal("", c.RejectReason); + Assert.Equal("event-1", c.ClusterKey); + Assert.True(c.NetEdgePct > 0m); + } + + [Fact] + public void Evaluate_rejects_out_of_price_band() + { + var c = MarketScannerService.Evaluate(1, Market(0.85m), DefaultSettings()); + Assert.False(c.Accepted); + Assert.Contains("Band", c.RejectReason); + } + + [Fact] + public void Evaluate_rejects_category_not_whitelisted() + { + var c = MarketScannerService.Evaluate(1, Market(0.95m, category: "Crypto"), DefaultSettings()); + Assert.False(c.Accepted); + Assert.Contains("Whitelist", c.RejectReason); + } + + [Fact] + public void Evaluate_rejects_blacklisted_market() + { + var s = DefaultSettings(); + s.BlacklistCsv = "dispute"; + var c = MarketScannerService.Evaluate(1, Market(0.95m, slug: "x-dispute"), s); + Assert.False(c.Accepted); + Assert.Equal("Blacklist", c.RejectReason); + } + + [Fact] + public void Evaluate_rejects_outside_resolution_window() + { + var c = MarketScannerService.Evaluate(1, Market(0.95m, hours: 100), DefaultSettings()); + Assert.False(c.Accepted); + Assert.Contains("Auflösung", c.RejectReason); + } + + [Fact] + public void Evaluate_rejects_insufficient_net_edge() + { + // Ask 0.98 (im Band), Sports-Fee 75 bps -> Netto-Edge ~1.29% < 1.5%. + var c = MarketScannerService.Evaluate(1, Market(0.98m), DefaultSettings()); + Assert.False(c.Accepted); + Assert.Contains("Netto-Edge", c.RejectReason); + } + + // ----- Orchestrierung ----- + + private sealed class FakeSource : IFarmingMarketSource + { + private readonly List _markets; + public FakeSource(params ScannedMarket[] markets) => _markets = new List(markets); + public Task> GetNearResolutionFavoritesAsync(RfSettings s, CancellationToken ct) + => Task.FromResult>(_markets); + } + + [Fact] + public async Task ScanAccount_persists_accepted_and_rejected_candidates() + { + var factory = new InMemoryContextFactory(o => new ResolutionFarmingDbContext(o)); + var candRepo = new EfRfCandidateRepository(factory); + var settingsRepo = new EfRfSettingsRepository(factory); + var source = new FakeSource( + Market(0.95m, slug: "good"), // akzeptiert + Market(0.80m, slug: "too-cheap")); // abgelehnt (Preisband) + + var svc = new MarketScannerService(source, candRepo, settingsRepo, new TerminalLogger()); + + var result = await svc.ScanAccountAsync(1, DefaultSettings(), CancellationToken.None); + + Assert.Equal(2, result.Count); + var stored = candRepo.GetRecent(1, 10); + Assert.Equal(2, stored.Count); + Assert.Contains(stored, c => c.Accepted && c.MarketSlug == "good"); + Assert.Contains(stored, c => !c.Accepted && c.MarketSlug == "too-cheap"); + } + } +}