using System; using System.Collections.Generic; using System.Linq; 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 { /// /// 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; private readonly IDecisionJournal _journal; public FarmingExecutionService( TradingState state, IRfSettingsRepository settingsRepo, IRfCandidateRepository candidateRepo, IRfPositionRepository positionRepo, IRfClosedTradeRepository closedRepo, TerminalLogger logger, IDecisionJournal journal) { _state = state; _settingsRepo = settingsRepo; _candidateRepo = candidateRepo; _positionRepo = positionRepo; _closedRepo = closedRepo; _logger = logger; _journal = journal; } 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 { RunCycle(stoppingToken); } catch (OperationCanceledException) { break; } catch (Exception ex) { _logger.Error($"RF-Execution Fehler: {ex.Message}"); } await Task.Delay(Interval, stoppingToken); } } /// /// 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 /// (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); // S-0: Entscheidung strukturiert journalisieren (Rejects stehen bereits in rf_candidates). _journal.Write(new DecisionRecord { SignalId = Guid.NewGuid().ToString("N"), ModuleName = "ResolutionFarming", AccountId = accountId, IsDemo = true, TokenId = c.TokenId, MarketSlug = c.MarketSlug, MarketQuestion = c.MarketQuestion, Side = "BUY", SignalPrice = c.Ask, MarketEndDate = c.EndDate, Decision = TradeDecision.Executed, Reason = DecisionReason.DemoFilled, Message = $"RF-Demo-Einstieg {shares:F2} Shares @ {c.Ask:F3} (Score {c.Score:F2})", ContextJson = System.Text.Json.JsonSerializer.Serialize(new { shares, sizeUsd, score = c.Score, netEdgePct = c.NetEdgePct, cluster = c.ClusterKey }) }); } return opened; } } }