RF-Slice 5: Demo-Execution + Resolution-Monitor (Phase RF-2)

Schliesst die Demo-Handelsschleife des ResolutionFarming:
- FarmingExecutionPlanner (pur): waehlt aus akzeptierten Kandidaten die zu oeffnenden
  Positionen + Groesse, priorisiert nach Score, unter Markt-/Cluster-/Gesamt-Limits,
  Kill-Switch und Tages-Drossel; dedupliziert Token, schreibt Exposure im Lauf fort.
- FarmingResolution (pur): baut aus Position + Ergebnis den RfClosedTrade (PnL/Fees/Redeem-Status).
- FarmingExecutionService: Demo-Einstieg (Maker-Fill 0 Fee via FarmingFillModel) -> rf_positions.
  Live-Execution bewusst geloggt/uebersprungen (Zielland). Demo-Balance NICHT mutiert
  (kein Shared-Account-Konflikt; PnL fliesst ueber rf_closed_trades).
- FarmingResolutionMonitorService: schliesst aufgeloeste Positionen, bucht GlobalPnl,
  Dual-Write ins Core-Trade-Log (Dashboard). Auflösungsstatus via IMarketResolutionSource.
- NullMarketResolutionSource als Default (nichts loest auf), bis Live-Data-API verdrahtet ist.

15 neue Tests (Planner 8, Resolution 3, Execution-Service 2, Monitor 2). Build 0 Fehler,
311 Tests gruen, --smoke-ui ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-10 11:40:30 +02:00
co-authored by Claude Opus 4.8
parent 80e6ad9b2d
commit 1c3a364df2
11 changed files with 680 additions and 1 deletions
@@ -0,0 +1,63 @@
using System.Collections.Generic;
using System.Linq;
using PolyTrader.Modules.ResolutionFarming.Models;
namespace PolyTrader.Modules.ResolutionFarming.Logic
{
/// <summary>
/// Reine Entry-Planung: entscheidet aus akzeptierten Kandidaten, offenen Positionen, Settings und
/// Bankroll-/Tageszustand, WELCHE Positionen in WELCHER USDC-Größe eröffnet werden priorisiert
/// nach Score, unter allen Risiko-Limits (Markt/Cluster/Gesamt), Kill-Switch und Tages-Drossel.
/// Der laufende Exposure-Zustand wird innerhalb eines Laufs fortgeschrieben, damit auch mehrere
/// gleichzeitige Öffnungen zusammen die Limits einhalten. Seiteneffektfrei → voll unit-getestet.
/// </summary>
public static class FarmingExecutionPlanner
{
/// <summary>Kleinste sinnvolle Ordergröße (USDC); darunter wird nicht eröffnet.</summary>
public const decimal MinOrderUsd = 1.0m;
public static List<(RfCandidate candidate, decimal sizeUsd)> Plan(
IReadOnlyList<RfCandidate> acceptedCandidates,
IReadOnlyList<RfPosition> openPositions,
RfSettings settings,
decimal bankrollUsd,
int newPositionsToday,
decimal realizedDailyPnlUsd)
{
var plan = new List<(RfCandidate, decimal)>();
// Kill-Switch: bei erreichtem Tagesverlust nichts Neues eröffnen.
if (FarmingRiskEngine.ShouldKill(realizedDailyPnlUsd, settings.DailyLossKillSwitchUsd))
return plan;
decimal totalExposure = openPositions.Sum(p => p.AmountUsd);
var clusterExposure = openPositions
.GroupBy(p => p.ClusterKey)
.ToDictionary(g => g.Key, g => g.Sum(p => p.AmountUsd));
var heldTokens = new HashSet<string>(openPositions.Select(p => p.TokenId));
int opened = 0;
foreach (var c in acceptedCandidates.Where(c => c.Accepted).OrderByDescending(c => c.Score))
{
if (FarmingRiskEngine.DailyLimitReached(newPositionsToday + opened, settings.MaxNewPositionsPerDay))
break;
if (heldTokens.Contains(c.TokenId)) continue; // Markt bereits gehalten (oder schon geplant)
clusterExposure.TryGetValue(c.ClusterKey, out var clusterExp);
decimal allowed = FarmingRiskEngine.AllowedPositionUsd(
settings.MaxPerMarketUsd, settings.MaxPerClusterPct, settings.MaxTotalExposurePct,
bankrollUsd, totalExposure, clusterExp, existingMarketExposureUsd: 0m);
if (allowed < MinOrderUsd) continue;
plan.Add((c, allowed));
totalExposure += allowed;
clusterExposure[c.ClusterKey] = clusterExp + allowed;
heldTokens.Add(c.TokenId);
opened++;
}
return plan;
}
}
}
@@ -0,0 +1,43 @@
using System;
using PolyTrader.Core.Trading;
using PolyTrader.Modules.ResolutionFarming.Models;
namespace PolyTrader.Modules.ResolutionFarming.Logic
{
/// <summary>
/// Reine Logik für den Positions-Abschluss bei Marktauflösung: baut aus einer offenen Position und
/// dem Ergebnis (Gewinner/Verlierer) den <see cref="RfClosedTrade"/> inkl. realisiertem PnL und Fees.
/// </summary>
public static class FarmingResolution
{
public static RfClosedTrade BuildClosedTrade(RfPosition p, bool isWinner, DateTime closedAt)
{
decimal exitPrice = isWinner ? 1.0m : 0.0m;
decimal realizedPnl = FarmingFillModel.ResolvePnl(p.Size, p.EntryPrice, p.EntryFeeBps, isWinner);
decimal entryCost = p.Size * p.EntryPrice;
decimal fees = FeeModel.FeeUsd(entryCost, p.EntryFeeBps); // Entry-Fee; die Auszahlung bei Resolution ist fee-frei
return new RfClosedTrade
{
AccountId = p.AccountId,
IsDemo = p.IsDemo,
TokenId = p.TokenId,
MarketSlug = p.MarketSlug,
MarketQuestion = p.MarketQuestion,
Outcome = p.Outcome,
Category = p.Category,
ClusterKey = p.ClusterKey,
EntryPrice = p.EntryPrice,
ExitPrice = exitPrice,
Size = p.Size,
RealizedPnl = realizedPnl,
PnlPercent = p.AmountUsd > 0m ? realizedPnl / p.AmountUsd * 100m : 0m,
TotalFees = fees,
OpenedAt = p.OpenedAt,
ClosedAt = closedAt,
ExitReason = "Market Resolved",
RedeemStatus = isWinner ? "Pending" : "None" // Gewinner müssen (on-chain) redeemt werden
};
}
}
}
@@ -42,7 +42,16 @@ namespace PolyTrader.Modules.ResolutionFarming
services.AddSingleton<Services.MarketScannerService>();
services.AddHostedService(sp => sp.GetRequiredService<Services.MarketScannerService>());
// Execution, Resolution-Monitor, Auto-Redeem und UI folgen in den nächsten Slices.
// Demo-Execution + Resolution-Monitor (Phase RF-2). Marktauflösungs-Quelle vorerst Null
// (nichts löst auf), bis die Live-Data-API im Zielland verdrahtet ist. Live-Order-Execution
// und On-Chain-Redeem sind ebenfalls Zielland-Arbeit.
services.AddSingleton<Services.IMarketResolutionSource, Services.NullMarketResolutionSource>();
services.AddSingleton<Services.FarmingExecutionService>();
services.AddHostedService(sp => sp.GetRequiredService<Services.FarmingExecutionService>());
services.AddSingleton<Services.FarmingResolutionMonitorService>();
services.AddHostedService(sp => sp.GetRequiredService<Services.FarmingResolutionMonitorService>());
// Live-Marktquelle, Live-Execution, On-Chain-Auto-Redeem und Kalibrierung folgen (Zielland).
}
public void RegisterUi(IModuleUiHost host, System.IServiceProvider services)
@@ -0,0 +1,126 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTrader.Modules.ResolutionFarming.Logic;
using PolyTrader.Modules.ResolutionFarming.Models;
using PolyTrader.Modules.ResolutionFarming.Persistence;
using PolyTraderSharp;
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;
public FarmingExecutionService(
TradingState state, IRfSettingsRepository settingsRepo, IRfCandidateRepository candidateRepo,
IRfPositionRepository positionRepo, IRfClosedTradeRepository closedRepo, TerminalLogger logger)
{
_state = state;
_settingsRepo = settingsRepo;
_candidateRepo = candidateRepo;
_positionRepo = positionRepo;
_closedRepo = closedRepo;
_logger = logger;
}
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
{
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.");
}
}
}
catch (OperationCanceledException) { break; }
catch (Exception ex) { _logger.Error($"RF-Execution Fehler: {ex.Message}"); }
await Task.Delay(Interval, stoppingToken);
}
}
/// <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);
}
return opened;
}
}
}
@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
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>
/// Prüft offene Farming-Positionen gegen Marktauflösung und schließt aufgelöste (Phase RF-2/3):
/// realisierten PnL buchen (rein via <see cref="FarmingResolution"/>), rf_position löschen,
/// Gesamt-PnL fortschreiben und generischen Core-Trade-Log schreiben (Dashboard). Der
/// Auflösungsstatus kommt aus einer <see cref="IMarketResolutionSource"/> (Live: Data-API).
/// On-Chain-Redeem der Gewinner-Shares ist eine spätere, clob.md-kritische Phase (Zielland).
/// </summary>
public class FarmingResolutionMonitorService : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromMinutes(10);
private readonly TradingState _state;
private readonly IRfPositionRepository _positionRepo;
private readonly IRfClosedTradeRepository _closedRepo;
private readonly IMarketResolutionSource _resolution;
private readonly ITradeLogRepository _coreLog;
private readonly TerminalLogger _logger;
public FarmingResolutionMonitorService(
TradingState state, IRfPositionRepository positionRepo, IRfClosedTradeRepository closedRepo,
IMarketResolutionSource resolution, ITradeLogRepository coreLog, TerminalLogger logger)
{
_state = state;
_positionRepo = positionRepo;
_closedRepo = closedRepo;
_resolution = resolution;
_coreLog = coreLog;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Delay(TimeSpan.FromSeconds(60), stoppingToken);
_logger.Info("ResolutionFarming-Monitor gestartet.");
while (!stoppingToken.IsCancellationRequested)
{
try
{
var closed = await CheckAndCloseAsync(stoppingToken);
if (closed.Count > 0)
_logger.Trade($"🏁 [RF-Monitor] {closed.Count} Position(en) bei Auflösung geschlossen.");
}
catch (OperationCanceledException) { break; }
catch (Exception ex) { _logger.Error($"RF-Monitor Fehler: {ex.Message}"); }
await Task.Delay(Interval, stoppingToken);
}
}
/// <summary>Testbarer Kern: prüft alle offenen Positionen und schließt aufgelöste. Liefert die geschlossenen Trades.</summary>
internal async Task<List<RfClosedTrade>> CheckAndCloseAsync(CancellationToken ct)
{
var result = new List<RfClosedTrade>();
foreach (var pos in _positionRepo.GetAllOpen())
{
if (ct.IsCancellationRequested) break;
var (isClosed, isWinner) = await _resolution.CheckAsync(pos.MarketSlug, pos.TokenId, ct);
if (!isClosed) continue;
var trade = FarmingResolution.BuildClosedTrade(pos, isWinner, DateTime.UtcNow);
_closedRepo.Insert(trade);
_positionRepo.Delete(pos.AccountId, pos.TokenId);
_state.GlobalPnl += trade.RealizedPnl;
// Dual-Write: generischer, modulübergreifender Core-Trade-Log (Dashboard).
_coreLog.Insert(new TradeRecord
{
ModuleName = "ResolutionFarming",
AccountId = trade.AccountId,
IsDemo = trade.IsDemo,
TokenId = trade.TokenId,
MarketQuestion = trade.MarketQuestion,
Outcome = trade.Outcome,
Side = "BUY",
EntryPrice = trade.EntryPrice,
ExitPrice = trade.ExitPrice,
Size = trade.Size,
RealizedPnl = trade.RealizedPnl,
PnlPercent = trade.PnlPercent,
OpenedAt = trade.OpenedAt,
ClosedAt = trade.ClosedAt,
ExitReason = trade.ExitReason
});
_logger.Trade($"🏆 [RF] {pos.MarketQuestion} aufgelöst ({(isWinner ? "Gewinner" : "Verlierer")}) PnL {trade.RealizedPnl:F2} USDC.");
result.Add(trade);
}
return result;
}
}
}
@@ -0,0 +1,22 @@
using System.Threading;
using System.Threading.Tasks;
namespace PolyTrader.Modules.ResolutionFarming.Services
{
/// <summary>
/// Quelle für den Auflösungsstatus eines Marktes. Trennt den (live-/API-gebundenen) Resolution-Check
/// von der Close-Orchestrierung, damit der Monitor ohne echte API testbar ist. Live-Implementierung
/// wickelt <c>PolymarketApiService.CheckMarketResolutionAsync</c> um (Zielland-Verdrahtung).
/// </summary>
public interface IMarketResolutionSource
{
Task<(bool isClosed, bool isWinner)> CheckAsync(string marketSlug, string tokenId, CancellationToken ct);
}
/// <summary>Platzhalter: nie aufgelöst. Hält den Monitor lauffähig, bis die Live-Quelle registriert ist.</summary>
public sealed class NullMarketResolutionSource : IMarketResolutionSource
{
public Task<(bool isClosed, bool isWinner)> CheckAsync(string marketSlug, string tokenId, CancellationToken ct)
=> Task.FromResult((false, false));
}
}