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