From 1c3a364df2d112b76dd5ecce83888e43161c5135 Mon Sep 17 00:00:00 2001 From: Richard Date: Fri, 10 Jul 2026 11:40:30 +0200 Subject: [PATCH] RF-Slice 5: Demo-Execution + Resolution-Monitor (Phase RF-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schliesst die Demo-Handelsschleife des ResolutionFarming: - FarmingExecutionPlanner (pur): waehlt aus akzeptierten Kandidaten die zu oeffnenden Positionen + Groesse, priorisiert nach Score, unter Markt-/Cluster-/Gesamt-Limits, Kill-Switch und Tages-Drossel; dedupliziert Token, schreibt Exposure im Lauf fort. - FarmingResolution (pur): baut aus Position + Ergebnis den RfClosedTrade (PnL/Fees/Redeem-Status). - FarmingExecutionService: Demo-Einstieg (Maker-Fill 0 Fee via FarmingFillModel) -> rf_positions. Live-Execution bewusst geloggt/uebersprungen (Zielland). Demo-Balance NICHT mutiert (kein Shared-Account-Konflikt; PnL fliesst ueber rf_closed_trades). - FarmingResolutionMonitorService: schliesst aufgeloeste Positionen, bucht GlobalPnl, Dual-Write ins Core-Trade-Log (Dashboard). Auflösungsstatus via IMarketResolutionSource. - NullMarketResolutionSource als Default (nichts loest auf), bis Live-Data-API verdrahtet ist. 15 neue Tests (Planner 8, Resolution 3, Execution-Service 2, Monitor 2). Build 0 Fehler, 311 Tests gruen, --smoke-ui ok. Co-Authored-By: Claude Opus 4.8 --- .../Logic/FarmingExecutionPlanner.cs | 63 +++++++++ .../Logic/FarmingResolution.cs | 43 ++++++ .../ResolutionFarmingModule.cs | 11 +- .../Services/FarmingExecutionService.cs | 126 ++++++++++++++++++ .../FarmingResolutionMonitorService.cs | 108 +++++++++++++++ .../Services/IMarketResolutionSource.cs | 22 +++ .../Fakes/FakeCoreTradeLogRepository.cs | 20 +++ .../FarmingExecutionPlannerTests.cs | 108 +++++++++++++++ .../FarmingExecutionServiceTests.cs | 67 ++++++++++ .../FarmingResolutionMonitorTests.cs | 69 ++++++++++ .../FarmingResolutionTests.cs | 44 ++++++ 11 files changed, 680 insertions(+), 1 deletion(-) create mode 100644 src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingExecutionPlanner.cs create mode 100644 src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingResolution.cs create mode 100644 src/PolyTrader.Modules.ResolutionFarming/Services/FarmingExecutionService.cs create mode 100644 src/PolyTrader.Modules.ResolutionFarming/Services/FarmingResolutionMonitorService.cs create mode 100644 src/PolyTrader.Modules.ResolutionFarming/Services/IMarketResolutionSource.cs create mode 100644 tests/PolyTrader.Tests/Fakes/FakeCoreTradeLogRepository.cs create mode 100644 tests/PolyTrader.Tests/FarmingExecutionPlannerTests.cs create mode 100644 tests/PolyTrader.Tests/FarmingExecutionServiceTests.cs create mode 100644 tests/PolyTrader.Tests/FarmingResolutionMonitorTests.cs create mode 100644 tests/PolyTrader.Tests/FarmingResolutionTests.cs diff --git a/src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingExecutionPlanner.cs b/src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingExecutionPlanner.cs new file mode 100644 index 0000000..56187d9 --- /dev/null +++ b/src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingExecutionPlanner.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using System.Linq; +using PolyTrader.Modules.ResolutionFarming.Models; + +namespace PolyTrader.Modules.ResolutionFarming.Logic +{ + /// + /// Reine Entry-Planung: entscheidet aus akzeptierten Kandidaten, offenen Positionen, Settings und + /// Bankroll-/Tageszustand, WELCHE Positionen in WELCHER USDC-Größe eröffnet werden – priorisiert + /// nach Score, unter allen Risiko-Limits (Markt/Cluster/Gesamt), Kill-Switch und Tages-Drossel. + /// Der laufende Exposure-Zustand wird innerhalb eines Laufs fortgeschrieben, damit auch mehrere + /// gleichzeitige Öffnungen zusammen die Limits einhalten. Seiteneffektfrei → voll unit-getestet. + /// + public static class FarmingExecutionPlanner + { + /// Kleinste sinnvolle Ordergröße (USDC); darunter wird nicht eröffnet. + public const decimal MinOrderUsd = 1.0m; + + public static List<(RfCandidate candidate, decimal sizeUsd)> Plan( + IReadOnlyList acceptedCandidates, + IReadOnlyList openPositions, + RfSettings settings, + decimal bankrollUsd, + int newPositionsToday, + decimal realizedDailyPnlUsd) + { + var plan = new List<(RfCandidate, decimal)>(); + + // Kill-Switch: bei erreichtem Tagesverlust nichts Neues eröffnen. + if (FarmingRiskEngine.ShouldKill(realizedDailyPnlUsd, settings.DailyLossKillSwitchUsd)) + return plan; + + decimal totalExposure = openPositions.Sum(p => p.AmountUsd); + var clusterExposure = openPositions + .GroupBy(p => p.ClusterKey) + .ToDictionary(g => g.Key, g => g.Sum(p => p.AmountUsd)); + var heldTokens = new HashSet(openPositions.Select(p => p.TokenId)); + int opened = 0; + + foreach (var c in acceptedCandidates.Where(c => c.Accepted).OrderByDescending(c => c.Score)) + { + if (FarmingRiskEngine.DailyLimitReached(newPositionsToday + opened, settings.MaxNewPositionsPerDay)) + break; + if (heldTokens.Contains(c.TokenId)) continue; // Markt bereits gehalten (oder schon geplant) + + clusterExposure.TryGetValue(c.ClusterKey, out var clusterExp); + decimal allowed = FarmingRiskEngine.AllowedPositionUsd( + settings.MaxPerMarketUsd, settings.MaxPerClusterPct, settings.MaxTotalExposurePct, + bankrollUsd, totalExposure, clusterExp, existingMarketExposureUsd: 0m); + + if (allowed < MinOrderUsd) continue; + + plan.Add((c, allowed)); + totalExposure += allowed; + clusterExposure[c.ClusterKey] = clusterExp + allowed; + heldTokens.Add(c.TokenId); + opened++; + } + + return plan; + } + } +} diff --git a/src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingResolution.cs b/src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingResolution.cs new file mode 100644 index 0000000..7f468d2 --- /dev/null +++ b/src/PolyTrader.Modules.ResolutionFarming/Logic/FarmingResolution.cs @@ -0,0 +1,43 @@ +using System; +using PolyTrader.Core.Trading; +using PolyTrader.Modules.ResolutionFarming.Models; + +namespace PolyTrader.Modules.ResolutionFarming.Logic +{ + /// + /// Reine Logik für den Positions-Abschluss bei Marktauflösung: baut aus einer offenen Position und + /// dem Ergebnis (Gewinner/Verlierer) den inkl. realisiertem PnL und Fees. + /// + public static class FarmingResolution + { + public static RfClosedTrade BuildClosedTrade(RfPosition p, bool isWinner, DateTime closedAt) + { + decimal exitPrice = isWinner ? 1.0m : 0.0m; + decimal realizedPnl = FarmingFillModel.ResolvePnl(p.Size, p.EntryPrice, p.EntryFeeBps, isWinner); + decimal entryCost = p.Size * p.EntryPrice; + decimal fees = FeeModel.FeeUsd(entryCost, p.EntryFeeBps); // Entry-Fee; die Auszahlung bei Resolution ist fee-frei + + return new RfClosedTrade + { + AccountId = p.AccountId, + IsDemo = p.IsDemo, + TokenId = p.TokenId, + MarketSlug = p.MarketSlug, + MarketQuestion = p.MarketQuestion, + Outcome = p.Outcome, + Category = p.Category, + ClusterKey = p.ClusterKey, + EntryPrice = p.EntryPrice, + ExitPrice = exitPrice, + Size = p.Size, + RealizedPnl = realizedPnl, + PnlPercent = p.AmountUsd > 0m ? realizedPnl / p.AmountUsd * 100m : 0m, + TotalFees = fees, + OpenedAt = p.OpenedAt, + ClosedAt = closedAt, + ExitReason = "Market Resolved", + RedeemStatus = isWinner ? "Pending" : "None" // Gewinner müssen (on-chain) redeemt werden + }; + } + } +} diff --git a/src/PolyTrader.Modules.ResolutionFarming/ResolutionFarmingModule.cs b/src/PolyTrader.Modules.ResolutionFarming/ResolutionFarmingModule.cs index d88c4d9..cfca717 100644 --- a/src/PolyTrader.Modules.ResolutionFarming/ResolutionFarmingModule.cs +++ b/src/PolyTrader.Modules.ResolutionFarming/ResolutionFarmingModule.cs @@ -42,7 +42,16 @@ namespace PolyTrader.Modules.ResolutionFarming services.AddSingleton(); services.AddHostedService(sp => sp.GetRequiredService()); - // Execution, Resolution-Monitor, Auto-Redeem und UI folgen in den nächsten Slices. + // Demo-Execution + Resolution-Monitor (Phase RF-2). Marktauflösungs-Quelle vorerst Null + // (nichts löst auf), bis die Live-Data-API im Zielland verdrahtet ist. Live-Order-Execution + // und On-Chain-Redeem sind ebenfalls Zielland-Arbeit. + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); + + // Live-Marktquelle, Live-Execution, On-Chain-Auto-Redeem und Kalibrierung folgen (Zielland). } public void RegisterUi(IModuleUiHost host, System.IServiceProvider services) diff --git a/src/PolyTrader.Modules.ResolutionFarming/Services/FarmingExecutionService.cs b/src/PolyTrader.Modules.ResolutionFarming/Services/FarmingExecutionService.cs new file mode 100644 index 0000000..1928d44 --- /dev/null +++ b/src/PolyTrader.Modules.ResolutionFarming/Services/FarmingExecutionService.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using PolyTrader.Modules.ResolutionFarming.Logic; +using PolyTrader.Modules.ResolutionFarming.Models; +using PolyTrader.Modules.ResolutionFarming.Persistence; +using PolyTraderSharp; +using PolyTraderSharp.Services; + +namespace PolyTrader.Modules.ResolutionFarming.Services +{ + /// + /// Eröffnet Farming-Positionen aus akzeptierten Kandidaten (Phase RF-2, Demo). Auswahl/Sizing per + /// reinem (Score-Priorisierung unter allen Risiko-Limits), + /// Demo-Fill per (Maker-Einstieg, 0 Fee – der Netto-Edge-Check nutzte + /// bereits konservativ den Taker-Satz). Positionen landen in rf_positions. + /// + /// Live-Execution (Maker-GTC via CLOB, Taker-Fallback nach Timeout) ist bewusst Zielland-Arbeit und + /// hier nur geloggt – die Entscheidungs-/Sizing-Logik ist identisch und getestet. + /// + public class FarmingExecutionService : BackgroundService + { + private static readonly TimeSpan Interval = TimeSpan.FromMinutes(5); + + private readonly TradingState _state; + private readonly IRfSettingsRepository _settingsRepo; + private readonly IRfCandidateRepository _candidateRepo; + private readonly IRfPositionRepository _positionRepo; + private readonly IRfClosedTradeRepository _closedRepo; + private readonly TerminalLogger _logger; + + public FarmingExecutionService( + TradingState state, IRfSettingsRepository settingsRepo, IRfCandidateRepository candidateRepo, + IRfPositionRepository positionRepo, IRfClosedTradeRepository closedRepo, TerminalLogger logger) + { + _state = state; + _settingsRepo = settingsRepo; + _candidateRepo = candidateRepo; + _positionRepo = positionRepo; + _closedRepo = closedRepo; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Delay(TimeSpan.FromSeconds(45), stoppingToken); // nach dem Scanner anlaufen + _logger.Info("ResolutionFarming-Execution gestartet."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + foreach (var settings in _settingsRepo.GetAll().Where(s => s.Enabled)) + { + if (stoppingToken.IsCancellationRequested) break; + if (!_state.Accounts.TryGetValue(settings.AccountId, out var account)) continue; + + if (account.IsDemo) + { + var opened = RunDemoForAccount(settings.AccountId, account.TotalBalance, settings); + if (opened.Count > 0) + _logger.Trade($"🌱 [RF-Demo] Konto {account.Name}: {opened.Count} Position(en) eröffnet."); + } + else + { + _logger.Info($"[RF-Execution] Konto {account.Name}: Live-Execution (Maker-GTC via CLOB) ist Zielland-Arbeit – übersprungen."); + } + } + } + catch (OperationCanceledException) { break; } + catch (Exception ex) { _logger.Error($"RF-Execution Fehler: {ex.Message}"); } + + await Task.Delay(Interval, stoppingToken); + } + } + + /// + /// Plant und eröffnet Demo-Positionen für einen Account (testbarer Kern). Liefert die eröffneten + /// Positionen. Bankroll = Referenz für die %-Limits; die Demo-Balance wird bewusst NICHT mutiert + /// (kein Konflikt mit anderen Modulen auf einem geteilten Konto – PnL fließt über rf_closed_trades). + /// + internal List RunDemoForAccount(int accountId, decimal bankrollUsd, RfSettings settings) + { + var accepted = _candidateRepo.GetRecent(accountId, 200).Where(c => c.Accepted).ToList(); + var open = _positionRepo.GetOpen(accountId); + DateTime today = DateTime.UtcNow.Date; + int todayCount = _positionRepo.CountOpenedSince(accountId, today); + decimal dailyPnl = _closedRepo.RealizedPnlSince(accountId, today); + + var plan = FarmingExecutionPlanner.Plan(accepted, open, settings, bankrollUsd, todayCount, dailyPnl); + + var opened = new List(); + foreach (var (c, sizeUsd) in plan) + { + decimal shares = FarmingFillModel.SharesForBudget(sizeUsd, c.Ask); + if (shares <= 0m) continue; + + const int makerFeeBps = 0; // Maker-Einstieg fee-frei + var pos = new RfPosition + { + AccountId = accountId, + TokenId = c.TokenId, + MarketSlug = c.MarketSlug, + MarketQuestion = c.MarketQuestion, + Outcome = c.Outcome, + Category = c.Category, + ClusterKey = c.ClusterKey, + EntryPrice = c.Ask, + Size = shares, + AmountUsd = FarmingFillModel.EntryCostWithFee(shares, c.Ask, makerFeeBps), + EntryFeeBps = makerFeeBps, + IsDemo = true, + OpenedAt = DateTime.UtcNow, + EndDate = c.EndDate, + Status = "Open" + }; + _positionRepo.Upsert(pos); + opened.Add(pos); + } + return opened; + } + } +} diff --git a/src/PolyTrader.Modules.ResolutionFarming/Services/FarmingResolutionMonitorService.cs b/src/PolyTrader.Modules.ResolutionFarming/Services/FarmingResolutionMonitorService.cs new file mode 100644 index 0000000..a71500f --- /dev/null +++ b/src/PolyTrader.Modules.ResolutionFarming/Services/FarmingResolutionMonitorService.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using PolyTrader.Core.Persistence; +using PolyTrader.Modules.ResolutionFarming.Logic; +using PolyTrader.Modules.ResolutionFarming.Models; +using PolyTrader.Modules.ResolutionFarming.Persistence; +using PolyTraderSharp; +using PolyTraderSharp.Models; +using PolyTraderSharp.Services; + +namespace PolyTrader.Modules.ResolutionFarming.Services +{ + /// + /// Prüft offene Farming-Positionen gegen Marktauflösung und schließt aufgelöste (Phase RF-2/3): + /// realisierten PnL buchen (rein via ), rf_position löschen, + /// Gesamt-PnL fortschreiben und generischen Core-Trade-Log schreiben (Dashboard). Der + /// Auflösungsstatus kommt aus einer (Live: Data-API). + /// On-Chain-Redeem der Gewinner-Shares ist eine spätere, clob.md-kritische Phase (Zielland). + /// + public class FarmingResolutionMonitorService : BackgroundService + { + private static readonly TimeSpan Interval = TimeSpan.FromMinutes(10); + + private readonly TradingState _state; + private readonly IRfPositionRepository _positionRepo; + private readonly IRfClosedTradeRepository _closedRepo; + private readonly IMarketResolutionSource _resolution; + private readonly ITradeLogRepository _coreLog; + private readonly TerminalLogger _logger; + + public FarmingResolutionMonitorService( + TradingState state, IRfPositionRepository positionRepo, IRfClosedTradeRepository closedRepo, + IMarketResolutionSource resolution, ITradeLogRepository coreLog, TerminalLogger logger) + { + _state = state; + _positionRepo = positionRepo; + _closedRepo = closedRepo; + _resolution = resolution; + _coreLog = coreLog; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken); + _logger.Info("ResolutionFarming-Monitor gestartet."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var closed = await CheckAndCloseAsync(stoppingToken); + if (closed.Count > 0) + _logger.Trade($"🏁 [RF-Monitor] {closed.Count} Position(en) bei Auflösung geschlossen."); + } + catch (OperationCanceledException) { break; } + catch (Exception ex) { _logger.Error($"RF-Monitor Fehler: {ex.Message}"); } + + await Task.Delay(Interval, stoppingToken); + } + } + + /// Testbarer Kern: prüft alle offenen Positionen und schließt aufgelöste. Liefert die geschlossenen Trades. + internal async Task> CheckAndCloseAsync(CancellationToken ct) + { + var result = new List(); + foreach (var pos in _positionRepo.GetAllOpen()) + { + if (ct.IsCancellationRequested) break; + + var (isClosed, isWinner) = await _resolution.CheckAsync(pos.MarketSlug, pos.TokenId, ct); + if (!isClosed) continue; + + var trade = FarmingResolution.BuildClosedTrade(pos, isWinner, DateTime.UtcNow); + _closedRepo.Insert(trade); + _positionRepo.Delete(pos.AccountId, pos.TokenId); + _state.GlobalPnl += trade.RealizedPnl; + + // Dual-Write: generischer, modulübergreifender Core-Trade-Log (Dashboard). + _coreLog.Insert(new TradeRecord + { + ModuleName = "ResolutionFarming", + AccountId = trade.AccountId, + IsDemo = trade.IsDemo, + TokenId = trade.TokenId, + MarketQuestion = trade.MarketQuestion, + Outcome = trade.Outcome, + Side = "BUY", + EntryPrice = trade.EntryPrice, + ExitPrice = trade.ExitPrice, + Size = trade.Size, + RealizedPnl = trade.RealizedPnl, + PnlPercent = trade.PnlPercent, + OpenedAt = trade.OpenedAt, + ClosedAt = trade.ClosedAt, + ExitReason = trade.ExitReason + }); + + _logger.Trade($"🏆 [RF] {pos.MarketQuestion} aufgelöst ({(isWinner ? "Gewinner" : "Verlierer")}) – PnL {trade.RealizedPnl:F2} USDC."); + result.Add(trade); + } + return result; + } + } +} diff --git a/src/PolyTrader.Modules.ResolutionFarming/Services/IMarketResolutionSource.cs b/src/PolyTrader.Modules.ResolutionFarming/Services/IMarketResolutionSource.cs new file mode 100644 index 0000000..099d5d2 --- /dev/null +++ b/src/PolyTrader.Modules.ResolutionFarming/Services/IMarketResolutionSource.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace PolyTrader.Modules.ResolutionFarming.Services +{ + /// + /// Quelle für den Auflösungsstatus eines Marktes. Trennt den (live-/API-gebundenen) Resolution-Check + /// von der Close-Orchestrierung, damit der Monitor ohne echte API testbar ist. Live-Implementierung + /// wickelt PolymarketApiService.CheckMarketResolutionAsync um (Zielland-Verdrahtung). + /// + public interface IMarketResolutionSource + { + Task<(bool isClosed, bool isWinner)> CheckAsync(string marketSlug, string tokenId, CancellationToken ct); + } + + /// Platzhalter: nie aufgelöst. Hält den Monitor lauffähig, bis die Live-Quelle registriert ist. + public sealed class NullMarketResolutionSource : IMarketResolutionSource + { + public Task<(bool isClosed, bool isWinner)> CheckAsync(string marketSlug, string tokenId, CancellationToken ct) + => Task.FromResult((false, false)); + } +} diff --git a/tests/PolyTrader.Tests/Fakes/FakeCoreTradeLogRepository.cs b/tests/PolyTrader.Tests/Fakes/FakeCoreTradeLogRepository.cs new file mode 100644 index 0000000..3d737f2 --- /dev/null +++ b/tests/PolyTrader.Tests/Fakes/FakeCoreTradeLogRepository.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using PolyTrader.Core.Persistence; +using PolyTraderSharp.Models; + +namespace PolyTrader.Tests.Fakes +{ + /// In-Memory-Stub für den generischen Core-Trade-Log (Dual-Write-Ziel der Module). + public sealed class FakeCoreTradeLogRepository : ITradeLogRepository + { + public List Inserted { get; } = new(); + + public void EnsureIndexes() { } + public void Insert(TradeRecord record) => Inserted.Add(record); + public List GetRecent(int limit) => Inserted.TakeLast(limit).ToList(); + public List Find(Expression> predicate) => Inserted.Where(predicate.Compile()).ToList(); + } +} diff --git a/tests/PolyTrader.Tests/FarmingExecutionPlannerTests.cs b/tests/PolyTrader.Tests/FarmingExecutionPlannerTests.cs new file mode 100644 index 0000000..0ef5c81 --- /dev/null +++ b/tests/PolyTrader.Tests/FarmingExecutionPlannerTests.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; +using System.Linq; +using PolyTrader.Modules.ResolutionFarming.Logic; +using PolyTrader.Modules.ResolutionFarming.Models; +using Xunit; + +namespace PolyTrader.Tests +{ + /// Sicherheitsnetz für die reine Entry-Planung (Score-Priorisierung unter allen Risiko-Limits). + public class FarmingExecutionPlannerTests + { + private static RfSettings Settings(decimal maxPerMarket = 25m, decimal clusterPct = 10m, + decimal totalPct = 60m, int maxPerDay = 20, decimal killUsd = 0m) => new() + { + MaxPerMarketUsd = maxPerMarket, MaxPerClusterPct = clusterPct, MaxTotalExposurePct = totalPct, + MaxNewPositionsPerDay = maxPerDay, DailyLossKillSwitchUsd = killUsd + }; + + private static RfCandidate Cand(string token, decimal score, string cluster = "c", decimal ask = 0.95m) => + new() { Accepted = true, TokenId = token, Score = score, ClusterKey = cluster, Ask = ask }; + + [Fact] + public void Plan_ranks_by_score_and_sizes_to_limits() + { + var plan = FarmingExecutionPlanner.Plan( + new[] { Cand("a", 3m, "c1"), Cand("b", 5m, "c2") }, + new List(), Settings(), bankrollUsd: 1000m, newPositionsToday: 0, realizedDailyPnlUsd: 0m); + + Assert.Equal(2, plan.Count); + Assert.Equal("b", plan[0].candidate.TokenId); // höherer Score zuerst + Assert.Equal(25m, plan[0].sizeUsd); // min(Markt 25, Cluster 100, Gesamt 600) + } + + [Fact] + public void Plan_skips_already_held_tokens() + { + var open = new List { new() { TokenId = "a", ClusterKey = "c1", AmountUsd = 10m } }; + var plan = FarmingExecutionPlanner.Plan( + new[] { Cand("a", 9m, "c1") }, open, Settings(), 1000m, 0, 0m); + + Assert.Empty(plan); + } + + [Fact] + public void Plan_cluster_limit_caps_second_open_in_same_cluster() + { + // Cluster 3% von 1000 = 30. Erste 25, zweite nur noch 5. + var plan = FarmingExecutionPlanner.Plan( + new[] { Cand("a", 5m, "cx"), Cand("b", 4m, "cx") }, + new List(), Settings(clusterPct: 3m), 1000m, 0, 0m); + + Assert.Equal(2, plan.Count); + Assert.Equal(25m, plan[0].sizeUsd); + Assert.Equal(5m, plan[1].sizeUsd); + } + + [Fact] + public void Plan_total_exposure_limit_caps() + { + // Gesamt 3% von 1000 = 30. Erste 25, zweite 5. + var plan = FarmingExecutionPlanner.Plan( + new[] { Cand("a", 5m, "c1"), Cand("b", 4m, "c2") }, + new List(), Settings(totalPct: 3m), 1000m, 0, 0m); + + Assert.Equal(25m, plan[0].sizeUsd); + Assert.Equal(5m, plan[1].sizeUsd); + } + + [Fact] + public void Plan_kill_switch_blocks_all() + { + var plan = FarmingExecutionPlanner.Plan( + new[] { Cand("a", 5m) }, new List(), + Settings(killUsd: 10m), 1000m, 0, realizedDailyPnlUsd: -10m); + + Assert.Empty(plan); + } + + [Fact] + public void Plan_daily_limit_stops_new_opens() + { + var plan = FarmingExecutionPlanner.Plan( + new[] { Cand("a", 5m, "c1"), Cand("b", 4m, "c2") }, + new List(), Settings(maxPerDay: 1), 1000m, newPositionsToday: 0, realizedDailyPnlUsd: 0m); + + Assert.Single(plan); + } + + [Fact] + public void Plan_dedupes_duplicate_candidate_tokens() + { + var plan = FarmingExecutionPlanner.Plan( + new[] { Cand("a", 5m, "c1"), Cand("a", 3m, "c1") }, + new List(), Settings(), 1000m, 0, 0m); + + Assert.Single(plan); + Assert.Equal(5m, plan[0].candidate.Score); // höherer Score gewinnt + } + + [Fact] + public void Plan_empty_when_bankroll_zero() + { + var plan = FarmingExecutionPlanner.Plan( + new[] { Cand("a", 5m) }, new List(), Settings(), bankrollUsd: 0m, 0, 0m); + Assert.Empty(plan); + } + } +} diff --git a/tests/PolyTrader.Tests/FarmingExecutionServiceTests.cs b/tests/PolyTrader.Tests/FarmingExecutionServiceTests.cs new file mode 100644 index 0000000..a5538a2 --- /dev/null +++ b/tests/PolyTrader.Tests/FarmingExecutionServiceTests.cs @@ -0,0 +1,67 @@ +using System.Linq; +using PolyTrader.Modules.ResolutionFarming.Models; +using PolyTrader.Modules.ResolutionFarming.Persistence.Ef; +using PolyTrader.Modules.ResolutionFarming.Services; +using PolyTrader.Tests.TestSupport; +using PolyTraderSharp; +using PolyTraderSharp.Services; +using Xunit; + +namespace PolyTrader.Tests +{ + /// Orchestrierungstest des Demo-Einstiegs: akzeptierte Kandidaten → geplante/geöffnete rf_positions. + public class FarmingExecutionServiceTests + { + private static RfSettings Settings() => new() + { + AccountId = 1, MaxPerMarketUsd = 25m, MaxPerClusterPct = 10m, MaxTotalExposurePct = 60m, + MaxNewPositionsPerDay = 20, DailyLossKillSwitchUsd = 0m + }; + + private static (FarmingExecutionService svc, EfRfPositionRepository posRepo, EfRfCandidateRepository candRepo) Build() + { + var factory = new InMemoryContextFactory(o => new ResolutionFarmingDbContext(o)); + var candRepo = new EfRfCandidateRepository(factory); + var posRepo = new EfRfPositionRepository(factory); + var closedRepo = new EfRfClosedTradeRepository(factory); + var settingsRepo = new EfRfSettingsRepository(factory); + var svc = new FarmingExecutionService(new TradingState(), settingsRepo, candRepo, posRepo, closedRepo, new TerminalLogger()); + return (svc, posRepo, candRepo); + } + + [Fact] + public void RunDemo_opens_positions_for_accepted_candidates() + { + var (svc, posRepo, candRepo) = Build(); + candRepo.Insert(new RfCandidate { AccountId = 1, Accepted = true, TokenId = "a", ClusterKey = "c1", Ask = 0.95m, Score = 5m }); + candRepo.Insert(new RfCandidate { AccountId = 1, Accepted = true, TokenId = "b", ClusterKey = "c2", Ask = 0.95m, Score = 3m }); + candRepo.Insert(new RfCandidate { AccountId = 1, Accepted = false, TokenId = "z", ClusterKey = "c3", Ask = 0.80m, Score = 9m }); // abgelehnt -> ignoriert + + var opened = svc.RunDemoForAccount(1, bankrollUsd: 1000m, Settings()); + + Assert.Equal(2, opened.Count); + var stored = posRepo.GetOpen(1); + Assert.Equal(2, stored.Count); + var a = stored.First(p => p.TokenId == "a"); + Assert.Equal(0.95m, a.EntryPrice); + Assert.Equal(26.31m, a.Size); // floor(25/0.95, 2 Dez.) + Assert.Equal(24.9945m, a.AmountUsd); // Maker (0 Fee): shares * ask + Assert.True(a.IsDemo); + Assert.Equal("Open", a.Status); + } + + [Fact] + public void RunDemo_skips_already_held_market() + { + var (svc, posRepo, candRepo) = Build(); + posRepo.Upsert(new RfPosition { AccountId = 1, TokenId = "a", ClusterKey = "c1", AmountUsd = 20m }); + candRepo.Insert(new RfCandidate { AccountId = 1, Accepted = true, TokenId = "a", ClusterKey = "c1", Ask = 0.95m, Score = 9m }); + candRepo.Insert(new RfCandidate { AccountId = 1, Accepted = true, TokenId = "b", ClusterKey = "c2", Ask = 0.95m, Score = 3m }); + + var opened = svc.RunDemoForAccount(1, 1000m, Settings()); + + Assert.Single(opened); + Assert.Equal("b", opened[0].TokenId); + } + } +} diff --git a/tests/PolyTrader.Tests/FarmingResolutionMonitorTests.cs b/tests/PolyTrader.Tests/FarmingResolutionMonitorTests.cs new file mode 100644 index 0000000..d3fc1f7 --- /dev/null +++ b/tests/PolyTrader.Tests/FarmingResolutionMonitorTests.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using PolyTrader.Modules.ResolutionFarming.Models; +using PolyTrader.Modules.ResolutionFarming.Persistence.Ef; +using PolyTrader.Modules.ResolutionFarming.Services; +using PolyTrader.Tests.Fakes; +using PolyTrader.Tests.TestSupport; +using PolyTraderSharp; +using PolyTraderSharp.Services; +using Xunit; + +namespace PolyTrader.Tests +{ + /// Orchestrierungstest des Resolution-Monitors: aufgelöste Positionen werden geschlossen, gebucht und dual-geschrieben. + public class FarmingResolutionMonitorTests + { + private sealed class FakeResolution : IMarketResolutionSource + { + private readonly Dictionary _byToken; + public FakeResolution(Dictionary byToken) => _byToken = byToken; + public Task<(bool isClosed, bool isWinner)> CheckAsync(string slug, string tokenId, CancellationToken ct) + => Task.FromResult(_byToken.TryGetValue(tokenId, out var r) ? r : (false, false)); + } + + [Fact] + public async Task Closes_resolved_winner_and_dual_writes() + { + var factory = new InMemoryContextFactory(o => new ResolutionFarmingDbContext(o)); + var posRepo = new EfRfPositionRepository(factory); + var closedRepo = new EfRfClosedTradeRepository(factory); + posRepo.Upsert(new RfPosition { AccountId = 1, TokenId = "a", MarketSlug = "m-a", Size = 100m, EntryPrice = 0.95m, AmountUsd = 95m, EntryFeeBps = 0, IsDemo = true }); + posRepo.Upsert(new RfPosition { AccountId = 1, TokenId = "b", MarketSlug = "m-b", Size = 50m, EntryPrice = 0.90m, AmountUsd = 45m, EntryFeeBps = 0, IsDemo = true }); + + var resolution = new FakeResolution(new() { ["a"] = (true, true) }); // nur a aufgelöst (Gewinner) + var coreLog = new FakeCoreTradeLogRepository(); + var state = new TradingState(); + var svc = new FarmingResolutionMonitorService(state, posRepo, closedRepo, resolution, coreLog, new TerminalLogger()); + + var closed = await svc.CheckAndCloseAsync(CancellationToken.None); + + Assert.Single(closed); + Assert.Equal(5m, closed[0].RealizedPnl); // 100 - 95 + Assert.Equal(5m, state.GlobalPnl); // fortgeschrieben + Assert.Null(posRepo.Find(1, "a")); // Position entfernt + Assert.NotNull(posRepo.Find(1, "b")); // b bleibt offen (nicht aufgelöst) + Assert.Single(closedRepo.Find(t => t.AccountId == 1)); + Assert.Single(coreLog.Inserted); // Dual-Write ins Core-Log + Assert.Equal("ResolutionFarming", coreLog.Inserted[0].ModuleName); + } + + [Fact] + public async Task Leaves_unresolved_positions_open() + { + var factory = new InMemoryContextFactory(o => new ResolutionFarmingDbContext(o)); + var posRepo = new EfRfPositionRepository(factory); + var closedRepo = new EfRfClosedTradeRepository(factory); + posRepo.Upsert(new RfPosition { AccountId = 1, TokenId = "a", MarketSlug = "m-a", Size = 100m, EntryPrice = 0.95m, AmountUsd = 95m }); + + var svc = new FarmingResolutionMonitorService(new TradingState(), posRepo, closedRepo, + new FakeResolution(new()), new FakeCoreTradeLogRepository(), new TerminalLogger()); + + var closed = await svc.CheckAndCloseAsync(CancellationToken.None); + + Assert.Empty(closed); + Assert.NotNull(posRepo.Find(1, "a")); + } + } +} diff --git a/tests/PolyTrader.Tests/FarmingResolutionTests.cs b/tests/PolyTrader.Tests/FarmingResolutionTests.cs new file mode 100644 index 0000000..d65c99c --- /dev/null +++ b/tests/PolyTrader.Tests/FarmingResolutionTests.cs @@ -0,0 +1,44 @@ +using System; +using PolyTrader.Modules.ResolutionFarming.Logic; +using PolyTrader.Modules.ResolutionFarming.Models; +using Xunit; + +namespace PolyTrader.Tests +{ + /// Sicherheitsnetz für den reinen Positions-Abschluss bei Auflösung (PnL, Fees, Redeem-Status). + public class FarmingResolutionTests + { + private static RfPosition Pos(int feeBps = 0) => new() + { + AccountId = 1, TokenId = "a", MarketQuestion = "Q", Size = 100m, EntryPrice = 0.95m, + AmountUsd = 95m, EntryFeeBps = feeBps, IsDemo = true, OpenedAt = DateTime.UtcNow.AddHours(-5) + }; + + [Fact] + public void Winner_maker_pays_out_one_per_share() + { + var t = FarmingResolution.BuildClosedTrade(Pos(), isWinner: true, DateTime.UtcNow); + Assert.Equal(1.0m, t.ExitPrice); + Assert.Equal(5m, t.RealizedPnl); // 100 - 95 - 0 + Assert.Equal(0m, t.TotalFees); + Assert.Equal("Pending", t.RedeemStatus); // Gewinner müssen redeemt werden + } + + [Fact] + public void Loser_loses_entry_cost() + { + var t = FarmingResolution.BuildClosedTrade(Pos(), isWinner: false, DateTime.UtcNow); + Assert.Equal(0.0m, t.ExitPrice); + Assert.Equal(-95m, t.RealizedPnl); + Assert.Equal("None", t.RedeemStatus); + } + + [Fact] + public void Taker_entry_fee_reduces_winner_pnl() + { + var t = FarmingResolution.BuildClosedTrade(Pos(feeBps: 100), isWinner: true, DateTime.UtcNow); + Assert.Equal(4.05m, t.RealizedPnl); // 100 - 95 - 0.95 + Assert.Equal(0.95m, t.TotalFees); + } + } +}