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,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>