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