RF-Slice 1: ResolutionFarming-Modul-Skelett + geldkritische pure Logik + Tests

Neues Strategiemodul PolyTrader.Modules.ResolutionFarming (IPolyTraderModule,
Name=ResolutionFarming, DbPrefix=rf_), in Solution/App/Tests + beide Modul-Listen
in Program.cs eingebunden. Skelett laedt (RegisterServices/UI noch no-op).

Geldkritische Entscheidungslogik pur und vollstaendig unit-getestet:
- FarmingRiskEngine: Netto-Edge nach Fees (NetEdge/NetEdgePct/HasEdge), Positionsgroesse
  unter Markt-/Cluster-/Gesamt-Exposure-Limits (AllowedPositionUsd), Kill-Switch, Tages-Drossel.
- FarmingScanner: Preisband, Kategorie-Whitelist, Blacklist, Cluster-Key (korrelierte
  Favoriten teilen einen Cluster), Kandidaten-Score.
- FarmingFillModel: Shares fuer Budget (2-Dezimal-Floor), Resolve-PnL (Auszahlung - Kosten -
  Entry-Fee, Maker/Taker), Einstiegskosten inkl. Fee. Nutzt Core.FeeModel.
- RfSettings (rf_settings) mit konservativen Defaults + PropertyGrid-Attributen.

39 neue Tests. Build 0 Fehler, 283 Tests gruen, --smoke-ui ok.
Persistenz (DbContext/Migration/Repos), Scanner-/Monitor-Jobs, Execution und UI folgen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-09 21:43:34 +02:00
co-authored by Claude Opus 4.8
parent 2f5270a2f3
commit c9eb11afe4
13 changed files with 570 additions and 2 deletions
@@ -0,0 +1,42 @@
using System;
using PolyTrader.Core.Trading;
namespace PolyTrader.Modules.ResolutionFarming.Logic
{
/// <summary>
/// Reines Fill-/PnL-Modell für ResolutionFarming (Demo-Betrieb Phase RF-2). Einstieg realistisch
/// mit Fee (Taker) bzw. ohne (Maker); PnL bei Auflösung (Gewinner-Share zahlt 1.0, Verlierer 0),
/// abzüglich Entry-Fee. So sind Demo-Ergebnisse als Live-Vorhersage brauchbar.
/// </summary>
public static class FarmingFillModel
{
/// <summary>
/// Shares für ein Budget zum Einstiegspreis, auf 2 Dezimalen abgerundet (Polymarket-Size-Tick).
/// </summary>
public static decimal SharesForBudget(decimal budgetUsd, decimal price)
{
if (budgetUsd <= 0m || price <= 0m) return 0m;
return Math.Floor(budgetUsd / price * 100m) / 100m;
}
/// <summary>
/// Realisierter PnL einer aufgelösten Farming-Position: Auszahlung (Gewinner: Shares × 1.0,
/// sonst 0) Einstiegskosten (Shares × Entry) Entry-Fee. <paramref name="entryFeeBps"/> = 0
/// für Maker-Einstieg (fee-frei), sonst der Taker-Satz.
/// </summary>
public static decimal ResolvePnl(decimal shares, decimal entryPrice, int entryFeeBps, bool isWinner)
{
decimal cost = shares * entryPrice;
decimal fee = FeeModel.FeeUsd(cost, entryFeeBps);
decimal payout = isWinner ? shares * 1.0m : 0m;
return payout - cost - fee;
}
/// <summary>Einstiegskosten inkl. Fee (was tatsächlich vom Guthaben abgeht).</summary>
public static decimal EntryCostWithFee(decimal shares, decimal entryPrice, int entryFeeBps)
{
decimal cost = shares * entryPrice;
return cost + FeeModel.FeeUsd(cost, entryFeeBps);
}
}
}
@@ -0,0 +1,61 @@
using System;
namespace PolyTrader.Modules.ResolutionFarming.Logic
{
/// <summary>
/// Reine, seiteneffektfreie Risiko-/Entscheidungslogik des ResolutionFarming. Bewusst aus den
/// Services herausgezogen, damit die geldkritischen Entscheidungen (Netto-Edge nach Fees,
/// Positionsgröße unter Cluster-/Exposure-Limits, Kill-Switch) vollständig unit-getestet werden.
/// „Das Risikomodell IST die Strategie" (Plan): ein verlorener 95-¢-Trade vernichtet ~19 gewonnene.
/// </summary>
public static class FarmingRiskEngine
{
/// <summary>
/// Netto-Edge pro Share in USDC: Auszahlung bei Gewinn (1.0) Ask Taker-Fee je Share.
/// Die Fee liegt auf dem Notional (Ask × Shares), pro Share also Ask × bps/10000. Bei
/// ungültigem Ask (≤0 oder ≥1) negativ, damit die Position nie als profitabel gilt.
/// </summary>
public static decimal NetEdge(decimal ask, int feeBps)
{
if (ask <= 0m || ask >= 1m) return -1m;
decimal feePerShare = ask * feeBps / 10000m;
return (1m - ask) - feePerShare;
}
/// <summary>Netto-Edge als Prozent des Einsatzes (Ask): NetEdge / Ask × 100.</summary>
public static decimal NetEdgePct(decimal ask, int feeBps)
{
if (ask <= 0m || ask >= 1m) return -100m;
return NetEdge(ask, feeBps) / ask * 100m;
}
/// <summary>Liegt der Netto-Edge nach Fees über der Mindestschwelle?</summary>
public static bool HasEdge(decimal ask, int feeBps, decimal minEdgePct) =>
NetEdgePct(ask, feeBps) >= minEdgePct;
/// <summary>
/// Erlaubter USDC-Einsatz für eine neue/aufstockende Position unter allen drei Limits:
/// je Markt (absolut), je Cluster (% der Bankroll) und Gesamt-Exposure (% der Bankroll).
/// Liefert 0, wenn irgendein Limit bereits erschöpft ist. Nie negativ.
/// </summary>
public static decimal AllowedPositionUsd(
decimal maxPerMarketUsd, decimal maxPerClusterPct, decimal maxTotalExposurePct,
decimal bankrollUsd, decimal currentTotalExposureUsd, decimal currentClusterExposureUsd,
decimal existingMarketExposureUsd)
{
if (bankrollUsd <= 0m) return 0m;
decimal marketRoom = Math.Max(0m, maxPerMarketUsd - existingMarketExposureUsd);
decimal clusterRoom = Math.Max(0m, bankrollUsd * maxPerClusterPct / 100m - currentClusterExposureUsd);
decimal totalRoom = Math.Max(0m, bankrollUsd * maxTotalExposurePct / 100m - currentTotalExposureUsd);
return Math.Min(marketRoom, Math.Min(clusterRoom, totalRoom));
}
/// <summary>Kill-Switch: realisierter Tagesverlust erreicht/unterschreitet Schwelle (Schwelle &gt; 0).</summary>
public static bool ShouldKill(decimal realizedDailyPnlUsd, decimal killSwitchUsd) =>
killSwitchUsd > 0m && realizedDailyPnlUsd <= -killSwitchUsd;
/// <summary>Tages-Drossel erreicht (Anzahl neuer Positionen heute ≥ Limit)?</summary>
public static bool DailyLimitReached(int newPositionsToday, int maxNewPositionsPerDay) =>
newPositionsToday >= maxNewPositionsPerDay;
}
}
@@ -0,0 +1,75 @@
using System;
using System.Linq;
namespace PolyTrader.Modules.ResolutionFarming.Logic
{
/// <summary>
/// Reine Filter-/Scoring-Logik des Markt-Scanners. Entscheidet aus Markt-Metadaten, ob ein
/// Favorit als Kandidat taugt (Preisband, Kategorie-Whitelist, Blacklist) und leitet den
/// Cluster-Key ab (korrelierte Favoriten dürfen nicht als unabhängige Wetten zählen).
/// </summary>
public static class FarmingScanner
{
/// <summary>Liegt der Ask im Favoriten-Preisband [min, max]?</summary>
public static bool InPriceBand(decimal ask, decimal minPrice, decimal maxPrice) =>
ask >= minPrice && ask <= maxPrice;
/// <summary>
/// Ist die Markt-Kategorie auf der Whitelist (CSV, case-insensitiv, Teil-Match in beide
/// Richtungen)? Leere Whitelist = alles erlaubt.
/// </summary>
public static bool CategoryAllowed(string? category, string? whitelistCsv)
{
var white = SplitCsv(whitelistCsv);
if (white.Length == 0) return true;
string c = (category ?? string.Empty).Trim().ToLowerInvariant();
if (c.Length == 0) return false;
return white.Any(w => c.Contains(w) || w.Contains(c));
}
/// <summary>
/// Steht der Markt (Slug oder Tags) auf der Blacklist (CSV, case-insensitiv, Teil-Match)?
/// Leere Blacklist = nichts geblockt.
/// </summary>
public static bool IsBlacklisted(string? slug, string? tags, string? blacklistCsv)
{
var black = SplitCsv(blacklistCsv);
if (black.Length == 0) return false;
string hay = ((slug ?? string.Empty) + " " + (tags ?? string.Empty)).ToLowerInvariant();
return black.Any(b => hay.Contains(b));
}
/// <summary>
/// Cluster-Key aus dem Ereignis-/Series-Slug (bevorzugt), sonst dem Markt-Slug. Korrelierte
/// Favoriten desselben Events teilen so einen Cluster (Cluster-Limits greifen). Normalisiert
/// (trim + lower). Leer, wenn beides fehlt.
/// </summary>
public static string ClusterKey(string? eventSlug, string? marketSlug)
{
string ev = (eventSlug ?? string.Empty).Trim().ToLowerInvariant();
if (ev.Length > 0) return ev;
return (marketSlug ?? string.Empty).Trim().ToLowerInvariant();
}
/// <summary>
/// Kandidaten-Score (höher = besser): Netto-Edge (%) gewichtet, plus ein Bonus für kürzere
/// Kapitalbindung (frühere Auflösung). Rein für Ranking/Priorisierung, nicht für die
/// Handelsentscheidung (die trifft <see cref="FarmingRiskEngine.HasEdge"/>).
/// </summary>
public static decimal Score(decimal netEdgePct, double hoursToResolution)
{
decimal edge = Math.Max(0m, netEdgePct);
// Bindungsbonus: 0 h → +2, 48 h → ~0. Linear geklemmt.
double h = Math.Max(0.0, hoursToResolution);
decimal timeBonus = (decimal)Math.Max(0.0, (48.0 - h) / 48.0) * 2m;
return edge + timeBonus;
}
private static string[] SplitCsv(string? csv) =>
(csv ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => s.ToLowerInvariant())
.Where(s => s.Length > 0)
.ToArray();
}
}
@@ -0,0 +1,90 @@
using System.ComponentModel;
namespace PolyTrader.Modules.ResolutionFarming.Models
{
/// <summary>
/// Modul-Settings je Account (Tabelle rf_settings). Steuert Marktauswahl, Risiko-Limits und
/// Ausführung des ResolutionFarming. Defaults sind konservativ (Plan Abschnitt 2.2/5).
/// </summary>
public class RfSettings
{
[Browsable(false)]
public int AccountId { get; set; }
[Category("01. Aktivierung")]
[DisplayName("Modul aktiv")]
[Description("Schaltet das ResolutionFarming für diesen Account scharf. Bei false wird nur gescannt (read-only).")]
public bool Enabled { get; set; } = false;
// ----- Marktauswahl -----
[Category("02. Marktauswahl")]
[DisplayName("Min. Preis (01)")]
[Description("Unteres Ende des Favoriten-Preisbands. Nur Favoriten mit Ask >= diesem Wert werden gehandelt. Default 0.90.")]
public decimal MinPrice { get; set; } = 0.90m;
[Category("02. Marktauswahl")]
[DisplayName("Max. Preis (01)")]
[Description("Oberes Ende des Preisbands. Über diesem Ask ist die Marge zu klein. Default 0.98.")]
public decimal MaxPrice { get; set; } = 0.98m;
[Category("02. Marktauswahl")]
[DisplayName("Max. Stunden bis Auflösung")]
[Description("Nur Märkte, die innerhalb dieses Fensters auflösen, werden gescannt (Kapitalbindung begrenzen). Default 48 h.")]
public int MaxHoursToResolution { get; set; } = 48;
[Category("02. Marktauswahl")]
[DisplayName("Kategorie-Whitelist (CSV)")]
[Description("Nur diese Markt-Kategorien handeln (Komma-getrennt). Krypto bewusst ausgeschlossen (1,8 % Fee frisst die Marge). Default: Sports,Politics,Geopolitics.")]
public string CategoryWhitelistCsv { get; set; } = "Sports,Politics,Geopolitics";
[Category("02. Marktauswahl")]
[DisplayName("Blacklist Slugs/Tags (CSV)")]
[Description("Marktarten mit Resolution-Streitigkeiten (UMA-Disputes) o.ä. ausschließen. Teil-Match auf Slug/Tags.")]
public string BlacklistCsv { get; set; } = "";
// ----- Risiko -----
[Category("03. Risiko")]
[DisplayName("Min. Netto-Edge (%)")]
[Description("Mindest-Netto-Edge NACH Fees: (1 Ask) Fee(Ask,Kategorie) >= dieser Wert. Default 1.5 %.")]
public decimal MinEdgePct { get; set; } = 1.5m;
[Category("03. Risiko")]
[DisplayName("Max. Einsatz je Markt (USDC)")]
[Description("Obergrenze des Einsatzes in einem einzelnen Markt. Default 25.")]
public decimal MaxPerMarketUsd { get; set; } = 25m;
[Category("03. Risiko")]
[DisplayName("Max. Anteil je Cluster (%)")]
[Description("Max. Anteil der Bankroll je Ereignis-Cluster (z. B. ein Spieltag). Korrelierte Favoriten teilen einen Cluster. Default 10 %.")]
public decimal MaxPerClusterPct { get; set; } = 10m;
[Category("03. Risiko")]
[DisplayName("Max. Gesamt-Exposure (%)")]
[Description("Max. Anteil der Bankroll gleichzeitig in offenen Positionen. Default 60 %.")]
public decimal MaxTotalExposurePct { get; set; } = 60m;
[Category("03. Risiko")]
[DisplayName("Max. neue Positionen/Tag")]
[Description("Drosselung der Neu-Einstiege pro Tag. Default 20.")]
public int MaxNewPositionsPerDay { get; set; } = 20;
[Category("03. Risiko")]
[DisplayName("Tagesverlust-Kill-Switch (USDC)")]
[Description("Überschreitet der realisierte Tagesverlust diesen Betrag, pausiert das Modul + Threema-Warnung. 0 = deaktiviert.")]
public decimal DailyLossKillSwitchUsd { get; set; } = 0m;
// ----- Ausführung -----
[Category("04. Ausführung")]
[DisplayName("Maker-Fill-Timeout (Min.)")]
[Description("Ruht der Maker-Einstieg (GTC-Limit, 0 Fees) so lange ohne Fill, greift die Taker-Fallback-Entscheidung. Default 15.")]
public int MakerFillTimeoutMinutes { get; set; } = 15;
[Category("04. Ausführung")]
[DisplayName("Taker-Fallback erlauben")]
[Description("Wenn true: nach Maker-Timeout per Taker füllen, sofern der Netto-Edge AUCH mit Taker-Fee noch >= Min-Edge ist. Wenn false: strikt Maker-only.")]
public bool AllowTakerFallback { get; set; } = true;
}
}
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\PolyTrader.Core\PolyTrader.Core.csproj" />
</ItemGroup>
<!-- Erlaubt dem Testprojekt, interne Methoden zu integrationstesten. -->
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>PolyTrader.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Modul trägt eigene WinForms-UI-Tabs bei. -->
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>
</Project>
@@ -0,0 +1,38 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Core.Modularity;
namespace PolyTrader.Modules.ResolutionFarming
{
/// <summary>
/// Strategiemodul „ResolutionFarming": kauft systematisch unterbewertete Favoriten (~0.900.98)
/// in bald auflösenden Märkten, hält bis zur Resolution und löst ein (Favorite-Longshot-Reversal).
/// Erstes neues Strategiemodul über Copytrading hinaus geringster Infrastrukturbedarf.
///
/// Aufbau (inkrementell): geldkritische Entscheidungslogik liegt pur und unit-getestet in
/// <c>Logic/</c> (FarmingRiskEngine, FarmingScanner, FarmingFillModel); Persistenz, Scanner-Job,
/// Execution und UI folgen in eigenen Slices. Live-CLOB/On-Chain-Redeem sind Zielland-Arbeit.
/// </summary>
public class ResolutionFarmingModule : IPolyTraderModule
{
public string Name => "ResolutionFarming";
public string DbPrefix => "rf_";
public void RegisterServices(IServiceCollection services, IConfiguration configuration)
{
// Slice 1: reines Logik-Skelett. Persistenz (DbContext/Repos), Scanner-/Monitor-Jobs und
// Execution werden in den folgenden Slices registriert.
}
public void RegisterUi(IModuleUiHost host, System.IServiceProvider services)
{
// Slice 1: noch keine UI-Tabs. Folgen mit Persistenz/Scanner.
}
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}