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:
Richard
2026-07-09 22:11:11 +02:00
co-authored by Claude Opus 4.8
parent 1b6e194b50
commit ad6c676e60
5 changed files with 327 additions and 1 deletions
@@ -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");
}
}
}