From c9eb11afe4d853ce72c80572390a426bafe08fa0 Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 9 Jul 2026 21:43:34 +0200 Subject: [PATCH] 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 --- PolyTrader.App.csproj | 1 + PolyTraderSharp.sln | 15 +++ Program.cs | 6 +- .../Logic/FarmingFillModel.cs | 42 +++++++ .../Logic/FarmingRiskEngine.cs | 61 ++++++++++ .../Logic/FarmingScanner.cs | 75 +++++++++++++ .../Models/RfSettings.cs | 90 +++++++++++++++ ...olyTrader.Modules.ResolutionFarming.csproj | 30 +++++ .../ResolutionFarmingModule.cs | 38 +++++++ .../PolyTrader.Tests/FarmingFillModelTests.cs | 49 +++++++++ .../FarmingRiskEngineTests.cs | 104 ++++++++++++++++++ tests/PolyTrader.Tests/FarmingScannerTests.cs | 60 ++++++++++ .../PolyTrader.Tests/PolyTrader.Tests.csproj | 1 + 13 files changed, 570 insertions(+), 2 deletions(-) create mode 100644 src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingFillModel.cs create mode 100644 src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingRiskEngine.cs create mode 100644 src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingScanner.cs create mode 100644 src/PolyTrader.Modules.ResolutionFarming/Models/RfSettings.cs create mode 100644 src/PolyTrader.Modules.ResolutionFarming/PolyTrader.Modules.ResolutionFarming.csproj create mode 100644 src/PolyTrader.Modules.ResolutionFarming/ResolutionFarmingModule.cs create mode 100644 tests/PolyTrader.Tests/FarmingFillModelTests.cs create mode 100644 tests/PolyTrader.Tests/FarmingRiskEngineTests.cs create mode 100644 tests/PolyTrader.Tests/FarmingScannerTests.cs 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 @@ +