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:
co-authored by
Claude Opus 4.8
parent
2f5270a2f3
commit
c9eb11afe4
@@ -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>
|
||||
@@ -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
@@ -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 > 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 (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;
|
||||
}
|
||||
}
|
||||
@@ -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.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
|
||||
/// <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>
|
||||
|
||||
Reference in New Issue
Block a user