diff --git a/PolyTrader.App.csproj b/PolyTrader.App.csproj
index 7ff1e0e..3159cd4 100644
--- a/PolyTrader.App.csproj
+++ b/PolyTrader.App.csproj
@@ -86,6 +86,7 @@
+
\ No newline at end of file
diff --git a/PolyTraderSharp.sln b/PolyTraderSharp.sln
index 5a96c73..4b0092d 100644
--- a/PolyTraderSharp.sln
+++ b/PolyTraderSharp.sln
@@ -21,6 +21,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolyTrader.Tests", "tests\PolyTrader.Tests\PolyTrader.Tests.csproj", "{E361C601-CC50-409F-8298-FD753DB4F6FF}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PolyTrader.Modules.ResolutionFarming", "src\PolyTrader.Modules.ResolutionFarming\PolyTrader.Modules.ResolutionFarming.csproj", "{98C70A7B-DC3D-48E3-BE5D-03F867E9BFA8}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -91,6 +93,18 @@ Global
{E361C601-CC50-409F-8298-FD753DB4F6FF}.Release|x64.Build.0 = Release|Any CPU
{E361C601-CC50-409F-8298-FD753DB4F6FF}.Release|x86.ActiveCfg = Release|Any CPU
{E361C601-CC50-409F-8298-FD753DB4F6FF}.Release|x86.Build.0 = Release|Any CPU
+ {98C70A7B-DC3D-48E3-BE5D-03F867E9BFA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {98C70A7B-DC3D-48E3-BE5D-03F867E9BFA8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {98C70A7B-DC3D-48E3-BE5D-03F867E9BFA8}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {98C70A7B-DC3D-48E3-BE5D-03F867E9BFA8}.Debug|x64.Build.0 = Debug|Any CPU
+ {98C70A7B-DC3D-48E3-BE5D-03F867E9BFA8}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {98C70A7B-DC3D-48E3-BE5D-03F867E9BFA8}.Debug|x86.Build.0 = Debug|Any CPU
+ {98C70A7B-DC3D-48E3-BE5D-03F867E9BFA8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {98C70A7B-DC3D-48E3-BE5D-03F867E9BFA8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {98C70A7B-DC3D-48E3-BE5D-03F867E9BFA8}.Release|x64.ActiveCfg = Release|Any CPU
+ {98C70A7B-DC3D-48E3-BE5D-03F867E9BFA8}.Release|x64.Build.0 = Release|Any CPU
+ {98C70A7B-DC3D-48E3-BE5D-03F867E9BFA8}.Release|x86.ActiveCfg = Release|Any CPU
+ {98C70A7B-DC3D-48E3-BE5D-03F867E9BFA8}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -101,6 +115,7 @@ Global
{3502A702-FA60-456E-BF23-05EAD02465E6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{FA3FC57B-EA9D-4703-B643-2874C8C0461E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{E361C601-CC50-409F-8298-FD753DB4F6FF} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {98C70A7B-DC3D-48E3-BE5D-03F867E9BFA8} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {60AA6BCF-B17E-4D52-A290-14154A3E97CF}
diff --git a/Program.cs b/Program.cs
index c0806c4..6e4e4de 100644
--- a/Program.cs
+++ b/Program.cs
@@ -12,6 +12,7 @@ using PolyTrader.Core.DependencyInjection;
using PolyTrader.Core.Modularity;
using PolyTrader.Modules.CopyTrading;
using PolyTrader.Modules.CopyTrading.Persistence;
+using PolyTrader.Modules.ResolutionFarming;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
@@ -58,7 +59,8 @@ internal static class Program
var modules = new System.Collections.Generic.List
{
- new CopyTradingModule()
+ new CopyTradingModule(),
+ new ResolutionFarmingModule()
};
AppHost = Host.CreateDefaultBuilder()
@@ -300,7 +302,7 @@ internal static class Program
{
ApplicationConfiguration.Initialize();
- var modules = new System.Collections.Generic.List { new CopyTradingModule() };
+ var modules = new System.Collections.Generic.List { new CopyTradingModule(), new ResolutionFarmingModule() };
using var host = Host.CreateDefaultBuilder()
.UseContentRoot(AppContext.BaseDirectory)
diff --git a/src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingFillModel.cs b/src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingFillModel.cs
new file mode 100644
index 0000000..006ac62
--- /dev/null
+++ b/src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingFillModel.cs
@@ -0,0 +1,42 @@
+using System;
+using PolyTrader.Core.Trading;
+
+namespace PolyTrader.Modules.ResolutionFarming.Logic
+{
+ ///
+ /// 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.
+ ///
+ public static class FarmingFillModel
+ {
+ ///
+ /// Shares für ein Budget zum Einstiegspreis, auf 2 Dezimalen abgerundet (Polymarket-Size-Tick).
+ ///
+ public static decimal SharesForBudget(decimal budgetUsd, decimal price)
+ {
+ if (budgetUsd <= 0m || price <= 0m) return 0m;
+ return Math.Floor(budgetUsd / price * 100m) / 100m;
+ }
+
+ ///
+ /// Realisierter PnL einer aufgelösten Farming-Position: Auszahlung (Gewinner: Shares × 1.0,
+ /// sonst 0) − Einstiegskosten (Shares × Entry) − Entry-Fee. = 0
+ /// für Maker-Einstieg (fee-frei), sonst der Taker-Satz.
+ ///
+ 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;
+ }
+
+ /// Einstiegskosten inkl. Fee (was tatsächlich vom Guthaben abgeht).
+ public static decimal EntryCostWithFee(decimal shares, decimal entryPrice, int entryFeeBps)
+ {
+ decimal cost = shares * entryPrice;
+ return cost + FeeModel.FeeUsd(cost, entryFeeBps);
+ }
+ }
+}
diff --git a/src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingRiskEngine.cs b/src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingRiskEngine.cs
new file mode 100644
index 0000000..00f10cb
--- /dev/null
+++ b/src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingRiskEngine.cs
@@ -0,0 +1,61 @@
+using System;
+
+namespace PolyTrader.Modules.ResolutionFarming.Logic
+{
+ ///
+ /// 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.
+ ///
+ public static class FarmingRiskEngine
+ {
+ ///
+ /// 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.
+ ///
+ public static decimal NetEdge(decimal ask, int feeBps)
+ {
+ if (ask <= 0m || ask >= 1m) return -1m;
+ decimal feePerShare = ask * feeBps / 10000m;
+ return (1m - ask) - feePerShare;
+ }
+
+ /// Netto-Edge als Prozent des Einsatzes (Ask): NetEdge / Ask × 100.
+ public static decimal NetEdgePct(decimal ask, int feeBps)
+ {
+ if (ask <= 0m || ask >= 1m) return -100m;
+ return NetEdge(ask, feeBps) / ask * 100m;
+ }
+
+ /// Liegt der Netto-Edge nach Fees über der Mindestschwelle?
+ public static bool HasEdge(decimal ask, int feeBps, decimal minEdgePct) =>
+ NetEdgePct(ask, feeBps) >= minEdgePct;
+
+ ///
+ /// 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.
+ ///
+ 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));
+ }
+
+ /// Kill-Switch: realisierter Tagesverlust erreicht/unterschreitet −Schwelle (Schwelle > 0).
+ public static bool ShouldKill(decimal realizedDailyPnlUsd, decimal killSwitchUsd) =>
+ killSwitchUsd > 0m && realizedDailyPnlUsd <= -killSwitchUsd;
+
+ /// Tages-Drossel erreicht (Anzahl neuer Positionen heute ≥ Limit)?
+ public static bool DailyLimitReached(int newPositionsToday, int maxNewPositionsPerDay) =>
+ newPositionsToday >= maxNewPositionsPerDay;
+ }
+}
diff --git a/src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingScanner.cs b/src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingScanner.cs
new file mode 100644
index 0000000..5c6b3cc
--- /dev/null
+++ b/src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingScanner.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Linq;
+
+namespace PolyTrader.Modules.ResolutionFarming.Logic
+{
+ ///
+ /// 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).
+ ///
+ public static class FarmingScanner
+ {
+ /// Liegt der Ask im Favoriten-Preisband [min, max]?
+ public static bool InPriceBand(decimal ask, decimal minPrice, decimal maxPrice) =>
+ ask >= minPrice && ask <= maxPrice;
+
+ ///
+ /// Ist die Markt-Kategorie auf der Whitelist (CSV, case-insensitiv, Teil-Match in beide
+ /// Richtungen)? Leere Whitelist = alles erlaubt.
+ ///
+ 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));
+ }
+
+ ///
+ /// Steht der Markt (Slug oder Tags) auf der Blacklist (CSV, case-insensitiv, Teil-Match)?
+ /// Leere Blacklist = nichts geblockt.
+ ///
+ 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));
+ }
+
+ ///
+ /// 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.
+ ///
+ 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();
+ }
+
+ ///
+ /// 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 ).
+ ///
+ 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();
+ }
+}
diff --git a/src/PolyTrader.Modules.ResolutionFarming/Models/RfSettings.cs b/src/PolyTrader.Modules.ResolutionFarming/Models/RfSettings.cs
new file mode 100644
index 0000000..9736723
--- /dev/null
+++ b/src/PolyTrader.Modules.ResolutionFarming/Models/RfSettings.cs
@@ -0,0 +1,90 @@
+using System.ComponentModel;
+
+namespace PolyTrader.Modules.ResolutionFarming.Models
+{
+ ///
+ /// Modul-Settings je Account (Tabelle rf_settings). Steuert Marktauswahl, Risiko-Limits und
+ /// Ausführung des ResolutionFarming. Defaults sind konservativ (Plan Abschnitt 2.2/5).
+ ///
+ 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 (0–1)")]
+ [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 (0–1)")]
+ [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;
+ }
+}
diff --git a/src/PolyTrader.Modules.ResolutionFarming/PolyTrader.Modules.ResolutionFarming.csproj b/src/PolyTrader.Modules.ResolutionFarming/PolyTrader.Modules.ResolutionFarming.csproj
new file mode 100644
index 0000000..152d146
--- /dev/null
+++ b/src/PolyTrader.Modules.ResolutionFarming/PolyTrader.Modules.ResolutionFarming.csproj
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+ <_Parameter1>PolyTrader.Tests
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+ net8.0-windows
+ enable
+ enable
+
+ true
+
+
+
diff --git a/src/PolyTrader.Modules.ResolutionFarming/ResolutionFarmingModule.cs b/src/PolyTrader.Modules.ResolutionFarming/ResolutionFarmingModule.cs
new file mode 100644
index 0000000..0a5a4ea
--- /dev/null
+++ b/src/PolyTrader.Modules.ResolutionFarming/ResolutionFarmingModule.cs
@@ -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
+{
+ ///
+ /// Strategiemodul „ResolutionFarming": kauft systematisch unterbewertete Favoriten (~0.90–0.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
+ /// Logic/ (FarmingRiskEngine, FarmingScanner, FarmingFillModel); Persistenz, Scanner-Job,
+ /// Execution und UI folgen in eigenen Slices. Live-CLOB/On-Chain-Redeem sind Zielland-Arbeit.
+ ///
+ 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;
+ }
+}
diff --git a/tests/PolyTrader.Tests/FarmingFillModelTests.cs b/tests/PolyTrader.Tests/FarmingFillModelTests.cs
new file mode 100644
index 0000000..fca0886
--- /dev/null
+++ b/tests/PolyTrader.Tests/FarmingFillModelTests.cs
@@ -0,0 +1,49 @@
+using PolyTrader.Modules.ResolutionFarming.Logic;
+using Xunit;
+using static PolyTrader.Modules.ResolutionFarming.Logic.FarmingFillModel;
+
+namespace PolyTrader.Tests
+{
+ /// Sicherheitsnetz für das Fill-/PnL-Modell (Demo-Realismus: Fee beim Einstieg, Auszahlung bei Auflösung).
+ public class FarmingFillModelTests
+ {
+ [Fact]
+ public void SharesForBudget_floors_to_two_decimals()
+ {
+ // 25 / 0.95 = 26.315... -> 26.31
+ Assert.Equal(26.31m, SharesForBudget(25m, 0.95m));
+ Assert.Equal(0m, SharesForBudget(0m, 0.95m));
+ Assert.Equal(0m, SharesForBudget(25m, 0m));
+ }
+
+ [Fact]
+ public void ResolvePnl_winner_maker_pays_no_fee()
+ {
+ // 100 Shares @ 0.95, Maker (0 Fee), Gewinner: 100 - 95 - 0 = 5
+ Assert.Equal(5m, ResolvePnl(100m, 0.95m, 0, isWinner: true));
+ }
+
+ [Fact]
+ public void ResolvePnl_winner_taker_subtracts_fee()
+ {
+ // 100 @ 0.95, Taker 100 bps: cost 95, fee 0.95, payout 100 -> 100 - 95 - 0.95 = 4.05
+ Assert.Equal(4.05m, ResolvePnl(100m, 0.95m, 100, isWinner: true));
+ }
+
+ [Fact]
+ public void ResolvePnl_loser_loses_cost_and_fee()
+ {
+ // Verlierer: payout 0 -> -(cost) - fee = -95 - 0.95 = -95.95
+ Assert.Equal(-95.95m, ResolvePnl(100m, 0.95m, 100, isWinner: false));
+ }
+
+ [Fact]
+ public void EntryCostWithFee_adds_taker_fee()
+ {
+ // 100 @ 0.95, 100 bps: 95 + 0.95 = 95.95
+ Assert.Equal(95.95m, EntryCostWithFee(100m, 0.95m, 100));
+ // Maker: nur cost
+ Assert.Equal(95m, EntryCostWithFee(100m, 0.95m, 0));
+ }
+ }
+}
diff --git a/tests/PolyTrader.Tests/FarmingRiskEngineTests.cs b/tests/PolyTrader.Tests/FarmingRiskEngineTests.cs
new file mode 100644
index 0000000..c5d7def
--- /dev/null
+++ b/tests/PolyTrader.Tests/FarmingRiskEngineTests.cs
@@ -0,0 +1,104 @@
+using PolyTrader.Modules.ResolutionFarming.Logic;
+using Xunit;
+using static PolyTrader.Modules.ResolutionFarming.Logic.FarmingRiskEngine;
+
+namespace PolyTrader.Tests
+{
+ ///
+ /// Sicherheitsnetz für die geldkritische Risiko-Logik des ResolutionFarming (Netto-Edge nach Fees,
+ /// Positionsgröße unter Cluster-/Exposure-Limits, Kill-Switch). „Das Risikomodell IST die Strategie."
+ ///
+ public class FarmingRiskEngineTests
+ {
+ // ----- NetEdge / NetEdgePct -----
+
+ [Fact]
+ public void NetEdge_subtracts_ask_and_per_share_fee()
+ {
+ // Ask 0.95, Fee 100 bps: (1-0.95) - 0.95*0.01 = 0.05 - 0.0095 = 0.0405
+ Assert.Equal(0.0405m, NetEdge(0.95m, 100));
+ // Fee 0 (Maker): reine Marge
+ Assert.Equal(0.05m, NetEdge(0.95m, 0));
+ }
+
+ [Theory]
+ [InlineData(0.0)] // ungültig
+ [InlineData(1.0)] // ungültig
+ [InlineData(1.5)] // ungültig
+ public void NetEdge_invalid_ask_is_negative(double ask)
+ {
+ Assert.True(NetEdge((decimal)ask, 0) < 0m);
+ }
+
+ // ----- HasEdge -----
+
+ [Theory]
+ [InlineData(0.95, 0, 1.5, true)] // 5.26% > 1.5%
+ [InlineData(0.95, 0, 6.0, false)] // 5.26% < 6%
+ [InlineData(0.99, 180, 1.5, false)] // Krypto-Fee frisst die Marge -> negativ
+ [InlineData(0.90, 75, 1.5, true)] // Sports-Fee, komfortabler Edge
+ public void HasEdge_respects_threshold_after_fees(double ask, int feeBps, double minEdgePct, bool expected)
+ {
+ Assert.Equal(expected, HasEdge((decimal)ask, feeBps, (decimal)minEdgePct));
+ }
+
+ // ----- AllowedPositionUsd -----
+
+ [Fact]
+ public void AllowedPosition_is_min_of_all_limits()
+ {
+ // bankroll 1000: cluster 10% -> 100, total 60% -> 600, market 25. Kein Bestand -> 25.
+ Assert.Equal(25m, AllowedPositionUsd(25m, 10m, 60m, 1000m, 0m, 0m, 0m));
+ }
+
+ [Fact]
+ public void AllowedPosition_market_cap_binds()
+ {
+ // Bestand im Markt 20 von 25 -> nur 5 Rest.
+ Assert.Equal(5m, AllowedPositionUsd(25m, 10m, 60m, 1000m, 0m, 0m, 20m));
+ }
+
+ [Fact]
+ public void AllowedPosition_cluster_cap_binds()
+ {
+ // Cluster-Exposure 95 von 100 -> 5 Rest.
+ Assert.Equal(5m, AllowedPositionUsd(25m, 10m, 60m, 1000m, 0m, 95m, 0m));
+ }
+
+ [Fact]
+ public void AllowedPosition_total_cap_binds()
+ {
+ // Gesamt-Exposure 590 von 600 -> 10 Rest.
+ Assert.Equal(10m, AllowedPositionUsd(25m, 10m, 60m, 1000m, 590m, 0m, 0m));
+ }
+
+ [Fact]
+ public void AllowedPosition_exhausted_or_zero_bankroll_returns_zero()
+ {
+ Assert.Equal(0m, AllowedPositionUsd(25m, 10m, 60m, 1000m, 0m, 0m, 25m)); // Markt voll
+ Assert.Equal(0m, AllowedPositionUsd(25m, 10m, 60m, 0m, 0m, 0m, 0m)); // keine Bankroll
+ Assert.Equal(0m, AllowedPositionUsd(25m, 10m, 60m, 1000m, 600m, 0m, 0m)); // Gesamt voll
+ }
+
+ // ----- Kill-Switch / Tages-Drossel -----
+
+ [Theory]
+ [InlineData(-10.0, 10.0, true)] // genau an der Schwelle
+ [InlineData(-11.0, 10.0, true)] // darunter
+ [InlineData(-9.0, 10.0, false)] // darüber
+ [InlineData(-50.0, 0.0, false)] // deaktiviert (0)
+ public void ShouldKill_on_daily_loss(double pnl, double threshold, bool expected)
+ {
+ Assert.Equal(expected, ShouldKill((decimal)pnl, (decimal)threshold));
+ }
+
+ [Theory]
+ [InlineData(19, 20, false)]
+ [InlineData(20, 20, true)]
+ [InlineData(21, 20, true)]
+ public void DailyLimitReached_counts_new_positions(int today, int max, bool expected)
+ {
+ Assert.Equal(expected, DailyLimitReached(today, max));
+ }
+ }
+}
diff --git a/tests/PolyTrader.Tests/FarmingScannerTests.cs b/tests/PolyTrader.Tests/FarmingScannerTests.cs
new file mode 100644
index 0000000..97347c9
--- /dev/null
+++ b/tests/PolyTrader.Tests/FarmingScannerTests.cs
@@ -0,0 +1,60 @@
+using PolyTrader.Modules.ResolutionFarming.Logic;
+using Xunit;
+using static PolyTrader.Modules.ResolutionFarming.Logic.FarmingScanner;
+
+namespace PolyTrader.Tests
+{
+ /// Sicherheitsnetz für die Scanner-Filter (Preisband, Kategorie, Blacklist, Cluster-Key, Score).
+ public class FarmingScannerTests
+ {
+ [Theory]
+ [InlineData(0.90, 0.90, 0.98, true)] // untere Grenze inkl.
+ [InlineData(0.98, 0.90, 0.98, true)] // obere Grenze inkl.
+ [InlineData(0.89, 0.90, 0.98, false)]
+ [InlineData(0.99, 0.90, 0.98, false)]
+ public void InPriceBand_is_inclusive(double ask, double min, double max, bool expected)
+ {
+ Assert.Equal(expected, InPriceBand((decimal)ask, (decimal)min, (decimal)max));
+ }
+
+ [Theory]
+ [InlineData("Sports", "Sports,Politics", true)]
+ [InlineData("sports", "Sports,Politics", true)] // case-insensitiv
+ [InlineData("Crypto", "Sports,Politics", false)]
+ [InlineData("Sports", "", true)] // leere Whitelist = alles
+ [InlineData("", "Sports,Politics", false)] // leere Kategorie
+ public void CategoryAllowed_matches_whitelist(string category, string whitelist, bool expected)
+ {
+ Assert.Equal(expected, CategoryAllowed(category, whitelist));
+ }
+
+ [Theory]
+ [InlineData("us-election-dispute", null, "dispute,uma", true)]
+ [InlineData("nfl-week1", "sports,nfl", "uma", false)]
+ [InlineData("anything", null, "", false)] // leere Blacklist
+ public void IsBlacklisted_matches_slug_or_tags(string slug, string? tags, string blacklist, bool expected)
+ {
+ Assert.Equal(expected, IsBlacklisted(slug, tags, blacklist));
+ }
+
+ [Fact]
+ public void ClusterKey_prefers_event_slug_then_market_slug()
+ {
+ Assert.Equal("epl-2026-03-14", ClusterKey("EPL-2026-03-14", "arsenal-win"));
+ Assert.Equal("arsenal-win", ClusterKey(null, "Arsenal-Win"));
+ Assert.Equal("arsenal-win", ClusterKey(" ", "arsenal-win"));
+ Assert.Equal("", ClusterKey(null, null));
+ }
+
+ [Fact]
+ public void Score_increases_with_edge_and_earlier_resolution()
+ {
+ // mehr Edge -> höherer Score
+ Assert.True(Score(5m, 24) > Score(2m, 24));
+ // frühere Auflösung -> höherer Score (Bindungsbonus)
+ Assert.True(Score(3m, 1) > Score(3m, 40));
+ // negativer Edge zählt als 0, aber Zeitbonus bleibt >= 0
+ Assert.True(Score(-5m, 10) >= 0m);
+ }
+ }
+}
diff --git a/tests/PolyTrader.Tests/PolyTrader.Tests.csproj b/tests/PolyTrader.Tests/PolyTrader.Tests.csproj
index 84d9d17..c8bf28a 100644
--- a/tests/PolyTrader.Tests/PolyTrader.Tests.csproj
+++ b/tests/PolyTrader.Tests/PolyTrader.Tests.csproj
@@ -19,6 +19,7 @@
+