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:
co-authored by
Claude Opus 4.8
parent
80e6ad9b2d
commit
1c3a364df2
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user