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