RF-Slice 3: Read-only-Scanner (Bewertungs-Pipeline + Persistenz)
Phase RF-1 (read-only): Scanner holt bald aufloesende Favoriten von einer IFarmingMarketSource, bewertet sie und schreibt JEDEN Kandidaten (akzeptiert wie abgelehnt inkl. Grund) nach rf_candidates. Platziert keine Orders. - MarketScannerService.Evaluate (pur/statisch, voll getestet): Filterkette mit Reject-Grund = erster Fehlschlag (Preisband -> Kategorie -> Blacklist -> Aufloesungsfenster -> Netto-Edge nach Fees). Cluster-Key + Score immer berechnet. - ScanAccountAsync: Beschaffung -> Bewertung -> Persistenz. BackgroundService-Loop (12min) ueber Accounts mit Settings; fehlertolerant. - IFarmingMarketSource + ScannedMarket-DTO trennen die (live-/API-gebundene) Beschaffung von der Bewertung -> Pipeline ohne echte Gamma/CLOB-API testbar. - NullFarmingMarketSource als Default: Modul laeuft ohne Live-Anbindung (die im Zielland registriert wird) und produziert dann korrekt keine Kandidaten. Hinweis: Zur Laufzeit fragt der Scanner rf_settings ab; bis die Migration angewendet ist, faengt der try/catch den fehlenden-Tabelle-Fehler ab (nur Log). Migration bewusst separat anzuwenden. 7 neue Tests (Evaluate-Faelle + Orchestrierung). Build 0 Fehler, 296 Tests gruen, --smoke-ui ok. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1b6e194b50
commit
ad6c676e60
@@ -35,7 +35,14 @@ namespace PolyTrader.Modules.ResolutionFarming
|
||||
services.AddSingleton<IRfPositionRepository, EfRfPositionRepository>();
|
||||
services.AddSingleton<IRfClosedTradeRepository, EfRfClosedTradeRepository>();
|
||||
|
||||
// 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.IFarmingMarketSource, Services.NullFarmingMarketSource>();
|
||||
services.AddSingleton<Services.MarketScannerService>();
|
||||
services.AddHostedService(sp => sp.GetRequiredService<Services.MarketScannerService>());
|
||||
|
||||
// Execution, Resolution-Monitor, Auto-Redeem und UI folgen in den nächsten Slices.
|
||||
}
|
||||
|
||||
public void RegisterUi(IModuleUiHost host, System.IServiceProvider services)
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IFarmingMarketSource
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<ScannedMarket>> GetNearResolutionFavoritesAsync(RfSettings settings, CancellationToken ct);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Read-only-Scanner (Phase RF-1): holt bald auflösende Favoriten von einer
|
||||
/// <see cref="IFarmingMarketSource"/>, 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 <see cref="Evaluate"/> (pur/statisch, voll unit-getestet);
|
||||
/// der Service umhüllt sie nur mit Beschaffung, Persistenz und Zeitsteuerung.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Scannt und persistiert die Kandidaten eines Accounts. Liefert die bewerteten Kandidaten.</summary>
|
||||
internal async Task<List<RfCandidate>> ScanAccountAsync(int accountId, RfSettings settings, CancellationToken ct)
|
||||
{
|
||||
var markets = await _source.GetNearResolutionFavoritesAsync(settings, ct);
|
||||
var result = new List<RfCandidate>(markets.Count);
|
||||
foreach (var m in markets)
|
||||
{
|
||||
var candidate = Evaluate(accountId, m, settings);
|
||||
_candidateRepo.Insert(candidate);
|
||||
result.Add(candidate);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class NullFarmingMarketSource : IFarmingMarketSource
|
||||
{
|
||||
public Task<IReadOnlyList<ScannedMarket>> GetNearResolutionFavoritesAsync(RfSettings settings, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<ScannedMarket>>(Array.Empty<ScannedMarket>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
|
||||
namespace PolyTrader.Modules.ResolutionFarming.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Roh-Eingabe des Scanners für einen einzelnen Favoriten-Ausgang eines bald auflösenden Marktes.
|
||||
/// Wird von einer <see cref="IFarmingMarketSource"/> geliefert (Live: Gamma-API + CLOB-Book; im
|
||||
/// Test: Fake). Die Bewertung/Filterung passiert rein in <c>FarmingScanner</c>/<c>FarmingRiskEngine</c>.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>Bester Ask des Favoriten (0–1).</summary>
|
||||
public decimal Ask { get; set; }
|
||||
|
||||
/// <summary>Taker-Fee-Satz (bps) laut Markt-Objekt; 0 = unbekannt → Kategorie-Fallback.</summary>
|
||||
public int FeeBps { get; set; }
|
||||
|
||||
public DateTime? EndDate { get; set; }
|
||||
|
||||
/// <summary>Stunden bis zur Auflösung (aus EndDate); für Score/Filter.</summary>
|
||||
public double HoursToResolution { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests der Scanner-Bewertung (pure Filterkette inkl. Reject-Gründe) und der Orchestrierung
|
||||
/// (Marktquelle → Bewertung → Persistenz) über eine Fake-Marktquelle + reales InMemory-Repo.
|
||||
/// </summary>
|
||||
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<ScannedMarket> _markets;
|
||||
public FakeSource(params ScannedMarket[] markets) => _markets = new List<ScannedMarket>(markets);
|
||||
public Task<IReadOnlyList<ScannedMarket>> GetNearResolutionFavoritesAsync(RfSettings s, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<ScannedMarket>>(_markets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScanAccount_persists_accepted_and_rejected_candidates()
|
||||
{
|
||||
var factory = new InMemoryContextFactory<ResolutionFarmingDbContext>(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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user