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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user