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);
+ }
+ }
+}