Files
PolyTraderSharp/src/PolyTrader.Modules.ResolutionFarming/Services/FarmingExecutionService.cs
T
RichardandClaude Opus 5 2278d1ccf3 ResolutionFarming haelt sich an Pause und Handelsmodi
Behebt #569.

GlobalTradingPaused, LiveTradingMode und DemoTradingMode wertete bisher allein
CopyTrading aus. Die Farming-Ausfuehrung pruefte nur RfSettings.Enabled und
eroeffnete weiter Positionen, auch wenn "Trading pausieren" gedrueckt war oder
der Demo-Modus auf Inaktiv stand. Der Pause-Knopf hielt damit nur die Haelfte
an.

Echtes Geld war noch nicht betroffen: RF fuehrt nur Demo-Konten aus, die
Live-Ausfuehrung wird als Zielland-Arbeit uebersprungen. Mit der
Live-Anbindung haette eine Pause ohne Wirkung vor echten Positionen gestanden.

Jetzt gelten dieselben Regeln wie im CopyTrading. Die Pause sperrt, und der
Modus des Kontotyps (Demo oder Live) muss auf Aktiv stehen. "Nur Verkauf"
sperrt ebenfalls, denn Farming kauft ausschliesslich. Protokolliert wird nur
der Wechsel zwischen gesperrt und frei, nicht jeder Fuenf-Minuten-Durchlauf.

VERHALTENSAENDERUNG: Demo-Farming eroeffnet nur noch bei Demo-Modus = Aktiv.
Bisher lief es unabhaengig vom Modus, und der steht nach jedem Start auf
Inaktiv.

Bewusst nicht gesperrt ist die Aufloesungsueberwachung. Sie bucht nur Maerkte
ab, die bereits aufgeloest sind, und sendet keine Orders. Hielte man sie an,
wiche der ausgewiesene PnL von der Wirklichkeit ab. Der Scanner schreibt nur
Kandidaten und bleibt ebenfalls aktiv.

Der Schleifenrumpf liegt jetzt als RunCycle() vor und ist damit testbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 13:40:45 +02:00

213 lines
9.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>
/// Eröffnet Farming-Positionen aus akzeptierten Kandidaten (Phase RF-2, Demo). Auswahl/Sizing per
/// reinem <see cref="FarmingExecutionPlanner"/> (Score-Priorisierung unter allen Risiko-Limits),
/// Demo-Fill per <see cref="FarmingFillModel"/> (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.
/// </summary>
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);
}
}
/// <summary>
/// Ein Durchlauf über alle freigeschalteten Konten - der testbare Kern der Schleife.
/// Liefert die Zahl der eröffneten Positionen.
/// </summary>
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;
}
/// <summary>
/// Warum dieses Konto gerade keine neuen Positionen eröffnen darf, oder <c>null</c>, wenn es darf (#569).
///
/// <para>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.</para>
///
/// <para>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.</para>
/// </summary>
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<int, string?> _lastBlockReason = new();
/// <summary>
/// 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.
/// </summary>
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.");
}
/// <summary>
/// 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).
/// </summary>
internal List<RfPosition> 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<RfPosition>();
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;
}
}
}