diff --git a/src/PolyTrader.Modules.ResolutionFarming/Services/FarmingExecutionService.cs b/src/PolyTrader.Modules.ResolutionFarming/Services/FarmingExecutionService.cs
index 9d8775c..2fbaac8 100644
--- a/src/PolyTrader.Modules.ResolutionFarming/Services/FarmingExecutionService.cs
+++ b/src/PolyTrader.Modules.ResolutionFarming/Services/FarmingExecutionService.cs
@@ -58,22 +58,7 @@ namespace PolyTrader.Modules.ResolutionFarming.Services
{
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.");
- }
- }
+ RunCycle(stoppingToken);
}
catch (OperationCanceledException) { break; }
catch (Exception ex) { _logger.Error($"RF-Execution Fehler: {ex.Message}"); }
@@ -82,6 +67,83 @@ namespace PolyTrader.Modules.ResolutionFarming.Services
}
}
+ ///
+ /// Ein Durchlauf über alle freigeschalteten Konten - der testbare Kern der Schleife.
+ /// Liefert die Zahl der eröffneten Positionen.
+ ///
+ internal int RunCycle(CancellationToken ct = default)
+ {
+ int total = 0;
+ foreach (var settings in _settingsRepo.GetAll().Where(s => s.Enabled))
+ {
+ if (ct.IsCancellationRequested) break;
+ if (!_state.Accounts.TryGetValue(settings.AccountId, out var account)) continue;
+
+ string? blocked = EntryBlockReason(_state, account.IsDemo);
+ ReportBlockChange(account, blocked);
+ if (blocked != null) continue;
+
+ if (account.IsDemo)
+ {
+ var opened = RunDemoForAccount(settings.AccountId, account.TotalBalance, settings);
+ total += opened.Count;
+ 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.");
+ }
+ }
+ return total;
+ }
+
+ ///
+ /// Warum dieses Konto gerade keine neuen Positionen eröffnen darf, oder null, wenn es darf (#569).
+ ///
+ /// Bis zum 13.09.2026 prüfte die Farming-Ausführung weder die globale Pause noch die
+ /// Handelsmodi - beides wertete allein CopyTrading aus. "Trading pausieren" hielt damit nur die
+ /// Hälfte an. Hier gelten jetzt dieselben Regeln wie dort: Pause sperrt, und der Modus des
+ /// Kontotyps (Demo oder Live) muss auf Aktiv stehen. "Nur Verkauf" sperrt ebenfalls, denn
+ /// Farming kauft ausschließlich.
+ ///
+ /// Die Auflösungsüberwachung bleibt davon bewusst unberührt: Sie bucht nur Märkte ab, die
+ /// bereits aufgelöst sind, und sendet keine Orders. Hielte man sie an, wiche der ausgewiesene
+ /// PnL von der Wirklichkeit ab.
+ ///
+ internal static string? EntryBlockReason(TradingState state, bool isDemo)
+ {
+ if (state.GlobalTradingPaused)
+ return "globale Pause ist aktiv";
+
+ TradingMode mode = isDemo ? state.DemoTradingMode : state.LiveTradingMode;
+ if (mode == TradingMode.Active)
+ return null;
+
+ string which = isDemo ? "Demo" : "Live";
+ return mode == TradingMode.SellOnly
+ ? $"{which}-Modus steht auf \"Nur Verkauf\" (Farming kauft ausschließlich)"
+ : $"{which}-Modus ist inaktiv";
+ }
+
+ private readonly Dictionary _lastBlockReason = new();
+
+ ///
+ /// Protokolliert nur den Wechsel zwischen gesperrt und frei. Die Schleife läuft alle fünf
+ /// Minuten; eine Zeile je Durchlauf und Konto würde das Protokoll zuschütten.
+ ///
+ private void ReportBlockChange(AccountState account, string? reason)
+ {
+ _lastBlockReason.TryGetValue(account.AccountId, out var last);
+ if (last == reason) return;
+ _lastBlockReason[account.AccountId] = reason;
+
+ if (reason != null)
+ _logger.Info($"⏸️ [RF-Execution] Konto {account.Name}: keine neuen Positionen – {reason}.");
+ else
+ _logger.Info($"▶️ [RF-Execution] Konto {account.Name}: Einstiege wieder freigegeben.");
+ }
+
///
/// 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
diff --git a/tests/PolyTrader.Tests/FarmingExecutionGateTests.cs b/tests/PolyTrader.Tests/FarmingExecutionGateTests.cs
new file mode 100644
index 0000000..e620a84
--- /dev/null
+++ b/tests/PolyTrader.Tests/FarmingExecutionGateTests.cs
@@ -0,0 +1,113 @@
+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.Models;
+using PolyTraderSharp.Services;
+using Xunit;
+
+namespace PolyTrader.Tests
+{
+ ///
+ /// Die Farming-Ausführung hält sich an Pause und Handelsmodi (#569). Bis dahin hielt
+ /// "Trading pausieren" nur CopyTrading an; Resolution Farming eröffnete weiter Positionen.
+ ///
+ public class FarmingExecutionGateTests
+ {
+ private sealed record Rig(FarmingExecutionService Svc, TradingState State, EfRfPositionRepository Positions);
+
+ /// Ein freigeschaltetes Konto mit zwei angenommenen Kandidaten, die ohne Sperre beide eröffnet würden.
+ private static Rig Build(bool demoAccount = true)
+ {
+ 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);
+
+ settingsRepo.Upsert(new RfSettings
+ {
+ AccountId = 1, Enabled = true, MaxPerMarketUsd = 25m, MaxPerClusterPct = 10m,
+ MaxTotalExposurePct = 60m, MaxNewPositionsPerDay = 20, DailyLossKillSwitchUsd = 0m
+ });
+ 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 });
+
+ var state = new TradingState();
+ state.Accounts[1] = new AccountState { AccountId = 1, Name = "Farming", IsDemo = demoAccount, TotalBalance = 1000m };
+
+ var svc = new FarmingExecutionService(state, settingsRepo, candRepo, posRepo, closedRepo, new TerminalLogger(), new FakeDecisionJournal());
+ return new Rig(svc, state, posRepo);
+ }
+
+ [Fact]
+ public void Aktiver_Demo_Modus_eroeffnet_Positionen()
+ {
+ var rig = Build();
+ rig.State.DemoTradingMode = TradingMode.Active;
+
+ Assert.Equal(2, rig.Svc.RunCycle());
+ Assert.Equal(2, rig.Positions.GetOpen(1).Count);
+ }
+
+ [Fact]
+ public void Globale_Pause_verhindert_Einstiege_trotz_aktivem_Modus()
+ {
+ var rig = Build();
+ rig.State.DemoTradingMode = TradingMode.Active;
+ rig.State.GlobalTradingPaused = true;
+
+ Assert.Equal(0, rig.Svc.RunCycle());
+ Assert.Empty(rig.Positions.GetOpen(1));
+ }
+
+ [Theory]
+ [InlineData(TradingMode.Inactive)]
+ [InlineData(TradingMode.SellOnly)] // Farming kauft ausschliesslich
+ public void Demo_Modus_ungleich_Aktiv_verhindert_Einstiege(TradingMode mode)
+ {
+ var rig = Build();
+ rig.State.DemoTradingMode = mode;
+
+ Assert.Equal(0, rig.Svc.RunCycle());
+ Assert.Empty(rig.Positions.GetOpen(1));
+ }
+
+ [Fact]
+ public void Aktiver_Live_Modus_gibt_Demo_Konten_nicht_frei()
+ {
+ var rig = Build();
+ rig.State.LiveTradingMode = TradingMode.Active;
+ rig.State.DemoTradingMode = TradingMode.Inactive;
+
+ Assert.Equal(0, rig.Svc.RunCycle());
+ }
+
+ [Fact]
+ public void Nach_Aufheben_der_Pause_geht_es_weiter()
+ {
+ var rig = Build();
+ rig.State.DemoTradingMode = TradingMode.Active;
+ rig.State.GlobalTradingPaused = true;
+ Assert.Equal(0, rig.Svc.RunCycle());
+
+ rig.State.GlobalTradingPaused = false;
+
+ Assert.Equal(2, rig.Svc.RunCycle());
+ }
+
+ [Fact]
+ public void Sperrgrund_nennt_die_Ursache()
+ {
+ var state = new TradingState { DemoTradingMode = TradingMode.Active, LiveTradingMode = TradingMode.SellOnly };
+
+ Assert.Null(FarmingExecutionService.EntryBlockReason(state, isDemo: true));
+ Assert.Contains("Nur Verkauf", FarmingExecutionService.EntryBlockReason(state, isDemo: false));
+
+ state.GlobalTradingPaused = true;
+ Assert.Contains("Pause", FarmingExecutionService.EntryBlockReason(state, isDemo: true));
+ }
+ }
+}