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
+1
View File
@@ -86,6 +86,7 @@
<ItemGroup>
<ProjectReference Include="src\PolyTrader.Core\PolyTrader.Core.csproj" />
<ProjectReference Include="src\PolyTrader.Modules.CopyTrading\PolyTrader.Modules.CopyTrading.csproj" />
<ProjectReference Include="src\PolyTrader.Modules.ResolutionFarming\PolyTrader.Modules.ResolutionFarming.csproj" />
</ItemGroup>
</Project>
+15
View File
@@ -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}
+4 -2
View File
@@ -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<IPolyTraderModule>
{
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<IPolyTraderModule> { new CopyTradingModule() };
var modules = new System.Collections.Generic.List<IPolyTraderModule> { new CopyTradingModule(), new ResolutionFarmingModule() };
using var host = Host.CreateDefaultBuilder()
.UseContentRoot(AppContext.BaseDirectory)
@@ -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;
}
}
@@ -0,0 +1,49 @@
using PolyTrader.Modules.ResolutionFarming.Logic;
using Xunit;
using static PolyTrader.Modules.ResolutionFarming.Logic.FarmingFillModel;
namespace PolyTrader.Tests
{
/// <summary>Sicherheitsnetz für das Fill-/PnL-Modell (Demo-Realismus: Fee beim Einstieg, Auszahlung bei Auflösung).</summary>
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));
}
}
}
@@ -0,0 +1,104 @@
using PolyTrader.Modules.ResolutionFarming.Logic;
using Xunit;
using static PolyTrader.Modules.ResolutionFarming.Logic.FarmingRiskEngine;
namespace PolyTrader.Tests
{
/// <summary>
/// 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."
/// </summary>
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));
}
}
}
@@ -0,0 +1,60 @@
using PolyTrader.Modules.ResolutionFarming.Logic;
using Xunit;
using static PolyTrader.Modules.ResolutionFarming.Logic.FarmingScanner;
namespace PolyTrader.Tests
{
/// <summary>Sicherheitsnetz für die Scanner-Filter (Preisband, Kategorie, Blacklist, Cluster-Key, Score).</summary>
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);
}
}
}
@@ -19,6 +19,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\PolyTrader.Core\PolyTrader.Core.csproj" />
<ProjectReference Include="..\..\src\PolyTrader.Modules.CopyTrading\PolyTrader.Modules.CopyTrading.csproj" />
<ProjectReference Include="..\..\src\PolyTrader.Modules.ResolutionFarming\PolyTrader.Modules.ResolutionFarming.csproj" />
</ItemGroup>
</Project>