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.AddSingleton<Services.MarketScannerService>();
|
||||||
services.AddHostedService(sp => sp.GetRequiredService<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)
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Linq.Expressions;
|
||||||
|
using PolyTrader.Core.Persistence;
|
||||||
|
using PolyTraderSharp.Models;
|
||||||
|
|
||||||
|
namespace PolyTrader.Tests.Fakes
|
||||||
|
{
|
||||||
|
/// <summary>In-Memory-Stub für den generischen Core-Trade-Log (Dual-Write-Ziel der Module).</summary>
|
||||||
|
public sealed class FakeCoreTradeLogRepository : ITradeLogRepository
|
||||||
|
{
|
||||||
|
public List<TradeRecord> Inserted { get; } = new();
|
||||||
|
|
||||||
|
public void EnsureIndexes() { }
|
||||||
|
public void Insert(TradeRecord record) => Inserted.Add(record);
|
||||||
|
public List<TradeRecord> GetRecent(int limit) => Inserted.TakeLast(limit).ToList();
|
||||||
|
public List<TradeRecord> Find(Expression<Func<TradeRecord, bool>> predicate) => Inserted.Where(predicate.Compile()).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using PolyTrader.Modules.ResolutionFarming.Logic;
|
||||||
|
using PolyTrader.Modules.ResolutionFarming.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PolyTrader.Tests
|
||||||
|
{
|
||||||
|
/// <summary>Sicherheitsnetz für die reine Entry-Planung (Score-Priorisierung unter allen Risiko-Limits).</summary>
|
||||||
|
public class FarmingExecutionPlannerTests
|
||||||
|
{
|
||||||
|
private static RfSettings Settings(decimal maxPerMarket = 25m, decimal clusterPct = 10m,
|
||||||
|
decimal totalPct = 60m, int maxPerDay = 20, decimal killUsd = 0m) => new()
|
||||||
|
{
|
||||||
|
MaxPerMarketUsd = maxPerMarket, MaxPerClusterPct = clusterPct, MaxTotalExposurePct = totalPct,
|
||||||
|
MaxNewPositionsPerDay = maxPerDay, DailyLossKillSwitchUsd = killUsd
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RfCandidate Cand(string token, decimal score, string cluster = "c", decimal ask = 0.95m) =>
|
||||||
|
new() { Accepted = true, TokenId = token, Score = score, ClusterKey = cluster, Ask = ask };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Plan_ranks_by_score_and_sizes_to_limits()
|
||||||
|
{
|
||||||
|
var plan = FarmingExecutionPlanner.Plan(
|
||||||
|
new[] { Cand("a", 3m, "c1"), Cand("b", 5m, "c2") },
|
||||||
|
new List<RfPosition>(), Settings(), bankrollUsd: 1000m, newPositionsToday: 0, realizedDailyPnlUsd: 0m);
|
||||||
|
|
||||||
|
Assert.Equal(2, plan.Count);
|
||||||
|
Assert.Equal("b", plan[0].candidate.TokenId); // höherer Score zuerst
|
||||||
|
Assert.Equal(25m, plan[0].sizeUsd); // min(Markt 25, Cluster 100, Gesamt 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Plan_skips_already_held_tokens()
|
||||||
|
{
|
||||||
|
var open = new List<RfPosition> { new() { TokenId = "a", ClusterKey = "c1", AmountUsd = 10m } };
|
||||||
|
var plan = FarmingExecutionPlanner.Plan(
|
||||||
|
new[] { Cand("a", 9m, "c1") }, open, Settings(), 1000m, 0, 0m);
|
||||||
|
|
||||||
|
Assert.Empty(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Plan_cluster_limit_caps_second_open_in_same_cluster()
|
||||||
|
{
|
||||||
|
// Cluster 3% von 1000 = 30. Erste 25, zweite nur noch 5.
|
||||||
|
var plan = FarmingExecutionPlanner.Plan(
|
||||||
|
new[] { Cand("a", 5m, "cx"), Cand("b", 4m, "cx") },
|
||||||
|
new List<RfPosition>(), Settings(clusterPct: 3m), 1000m, 0, 0m);
|
||||||
|
|
||||||
|
Assert.Equal(2, plan.Count);
|
||||||
|
Assert.Equal(25m, plan[0].sizeUsd);
|
||||||
|
Assert.Equal(5m, plan[1].sizeUsd);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Plan_total_exposure_limit_caps()
|
||||||
|
{
|
||||||
|
// Gesamt 3% von 1000 = 30. Erste 25, zweite 5.
|
||||||
|
var plan = FarmingExecutionPlanner.Plan(
|
||||||
|
new[] { Cand("a", 5m, "c1"), Cand("b", 4m, "c2") },
|
||||||
|
new List<RfPosition>(), Settings(totalPct: 3m), 1000m, 0, 0m);
|
||||||
|
|
||||||
|
Assert.Equal(25m, plan[0].sizeUsd);
|
||||||
|
Assert.Equal(5m, plan[1].sizeUsd);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Plan_kill_switch_blocks_all()
|
||||||
|
{
|
||||||
|
var plan = FarmingExecutionPlanner.Plan(
|
||||||
|
new[] { Cand("a", 5m) }, new List<RfPosition>(),
|
||||||
|
Settings(killUsd: 10m), 1000m, 0, realizedDailyPnlUsd: -10m);
|
||||||
|
|
||||||
|
Assert.Empty(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Plan_daily_limit_stops_new_opens()
|
||||||
|
{
|
||||||
|
var plan = FarmingExecutionPlanner.Plan(
|
||||||
|
new[] { Cand("a", 5m, "c1"), Cand("b", 4m, "c2") },
|
||||||
|
new List<RfPosition>(), Settings(maxPerDay: 1), 1000m, newPositionsToday: 0, realizedDailyPnlUsd: 0m);
|
||||||
|
|
||||||
|
Assert.Single(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Plan_dedupes_duplicate_candidate_tokens()
|
||||||
|
{
|
||||||
|
var plan = FarmingExecutionPlanner.Plan(
|
||||||
|
new[] { Cand("a", 5m, "c1"), Cand("a", 3m, "c1") },
|
||||||
|
new List<RfPosition>(), Settings(), 1000m, 0, 0m);
|
||||||
|
|
||||||
|
Assert.Single(plan);
|
||||||
|
Assert.Equal(5m, plan[0].candidate.Score); // höherer Score gewinnt
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Plan_empty_when_bankroll_zero()
|
||||||
|
{
|
||||||
|
var plan = FarmingExecutionPlanner.Plan(
|
||||||
|
new[] { Cand("a", 5m) }, new List<RfPosition>(), Settings(), bankrollUsd: 0m, 0, 0m);
|
||||||
|
Assert.Empty(plan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using PolyTrader.Modules.ResolutionFarming.Models;
|
||||||
|
using PolyTrader.Modules.ResolutionFarming.Persistence.Ef;
|
||||||
|
using PolyTrader.Modules.ResolutionFarming.Services;
|
||||||
|
using PolyTrader.Tests.TestSupport;
|
||||||
|
using PolyTraderSharp;
|
||||||
|
using PolyTraderSharp.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PolyTrader.Tests
|
||||||
|
{
|
||||||
|
/// <summary>Orchestrierungstest des Demo-Einstiegs: akzeptierte Kandidaten → geplante/geöffnete rf_positions.</summary>
|
||||||
|
public class FarmingExecutionServiceTests
|
||||||
|
{
|
||||||
|
private static RfSettings Settings() => new()
|
||||||
|
{
|
||||||
|
AccountId = 1, MaxPerMarketUsd = 25m, MaxPerClusterPct = 10m, MaxTotalExposurePct = 60m,
|
||||||
|
MaxNewPositionsPerDay = 20, DailyLossKillSwitchUsd = 0m
|
||||||
|
};
|
||||||
|
|
||||||
|
private static (FarmingExecutionService svc, EfRfPositionRepository posRepo, EfRfCandidateRepository candRepo) Build()
|
||||||
|
{
|
||||||
|
var factory = new InMemoryContextFactory<ResolutionFarmingDbContext>(o => new ResolutionFarmingDbContext(o));
|
||||||
|
var candRepo = new EfRfCandidateRepository(factory);
|
||||||
|
var posRepo = new EfRfPositionRepository(factory);
|
||||||
|
var closedRepo = new EfRfClosedTradeRepository(factory);
|
||||||
|
var settingsRepo = new EfRfSettingsRepository(factory);
|
||||||
|
var svc = new FarmingExecutionService(new TradingState(), settingsRepo, candRepo, posRepo, closedRepo, new TerminalLogger());
|
||||||
|
return (svc, posRepo, candRepo);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RunDemo_opens_positions_for_accepted_candidates()
|
||||||
|
{
|
||||||
|
var (svc, posRepo, candRepo) = Build();
|
||||||
|
candRepo.Insert(new RfCandidate { AccountId = 1, Accepted = true, TokenId = "a", ClusterKey = "c1", Ask = 0.95m, Score = 5m });
|
||||||
|
candRepo.Insert(new RfCandidate { AccountId = 1, Accepted = true, TokenId = "b", ClusterKey = "c2", Ask = 0.95m, Score = 3m });
|
||||||
|
candRepo.Insert(new RfCandidate { AccountId = 1, Accepted = false, TokenId = "z", ClusterKey = "c3", Ask = 0.80m, Score = 9m }); // abgelehnt -> ignoriert
|
||||||
|
|
||||||
|
var opened = svc.RunDemoForAccount(1, bankrollUsd: 1000m, Settings());
|
||||||
|
|
||||||
|
Assert.Equal(2, opened.Count);
|
||||||
|
var stored = posRepo.GetOpen(1);
|
||||||
|
Assert.Equal(2, stored.Count);
|
||||||
|
var a = stored.First(p => p.TokenId == "a");
|
||||||
|
Assert.Equal(0.95m, a.EntryPrice);
|
||||||
|
Assert.Equal(26.31m, a.Size); // floor(25/0.95, 2 Dez.)
|
||||||
|
Assert.Equal(24.9945m, a.AmountUsd); // Maker (0 Fee): shares * ask
|
||||||
|
Assert.True(a.IsDemo);
|
||||||
|
Assert.Equal("Open", a.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RunDemo_skips_already_held_market()
|
||||||
|
{
|
||||||
|
var (svc, posRepo, candRepo) = Build();
|
||||||
|
posRepo.Upsert(new RfPosition { AccountId = 1, TokenId = "a", ClusterKey = "c1", AmountUsd = 20m });
|
||||||
|
candRepo.Insert(new RfCandidate { AccountId = 1, Accepted = true, TokenId = "a", ClusterKey = "c1", Ask = 0.95m, Score = 9m });
|
||||||
|
candRepo.Insert(new RfCandidate { AccountId = 1, Accepted = true, TokenId = "b", ClusterKey = "c2", Ask = 0.95m, Score = 3m });
|
||||||
|
|
||||||
|
var opened = svc.RunDemoForAccount(1, 1000m, Settings());
|
||||||
|
|
||||||
|
Assert.Single(opened);
|
||||||
|
Assert.Equal("b", opened[0].TokenId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using PolyTrader.Modules.ResolutionFarming.Models;
|
||||||
|
using PolyTrader.Modules.ResolutionFarming.Persistence.Ef;
|
||||||
|
using PolyTrader.Modules.ResolutionFarming.Services;
|
||||||
|
using PolyTrader.Tests.Fakes;
|
||||||
|
using PolyTrader.Tests.TestSupport;
|
||||||
|
using PolyTraderSharp;
|
||||||
|
using PolyTraderSharp.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PolyTrader.Tests
|
||||||
|
{
|
||||||
|
/// <summary>Orchestrierungstest des Resolution-Monitors: aufgelöste Positionen werden geschlossen, gebucht und dual-geschrieben.</summary>
|
||||||
|
public class FarmingResolutionMonitorTests
|
||||||
|
{
|
||||||
|
private sealed class FakeResolution : IMarketResolutionSource
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, (bool, bool)> _byToken;
|
||||||
|
public FakeResolution(Dictionary<string, (bool, bool)> byToken) => _byToken = byToken;
|
||||||
|
public Task<(bool isClosed, bool isWinner)> CheckAsync(string slug, string tokenId, CancellationToken ct)
|
||||||
|
=> Task.FromResult(_byToken.TryGetValue(tokenId, out var r) ? r : (false, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Closes_resolved_winner_and_dual_writes()
|
||||||
|
{
|
||||||
|
var factory = new InMemoryContextFactory<ResolutionFarmingDbContext>(o => new ResolutionFarmingDbContext(o));
|
||||||
|
var posRepo = new EfRfPositionRepository(factory);
|
||||||
|
var closedRepo = new EfRfClosedTradeRepository(factory);
|
||||||
|
posRepo.Upsert(new RfPosition { AccountId = 1, TokenId = "a", MarketSlug = "m-a", Size = 100m, EntryPrice = 0.95m, AmountUsd = 95m, EntryFeeBps = 0, IsDemo = true });
|
||||||
|
posRepo.Upsert(new RfPosition { AccountId = 1, TokenId = "b", MarketSlug = "m-b", Size = 50m, EntryPrice = 0.90m, AmountUsd = 45m, EntryFeeBps = 0, IsDemo = true });
|
||||||
|
|
||||||
|
var resolution = new FakeResolution(new() { ["a"] = (true, true) }); // nur a aufgelöst (Gewinner)
|
||||||
|
var coreLog = new FakeCoreTradeLogRepository();
|
||||||
|
var state = new TradingState();
|
||||||
|
var svc = new FarmingResolutionMonitorService(state, posRepo, closedRepo, resolution, coreLog, new TerminalLogger());
|
||||||
|
|
||||||
|
var closed = await svc.CheckAndCloseAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Single(closed);
|
||||||
|
Assert.Equal(5m, closed[0].RealizedPnl); // 100 - 95
|
||||||
|
Assert.Equal(5m, state.GlobalPnl); // fortgeschrieben
|
||||||
|
Assert.Null(posRepo.Find(1, "a")); // Position entfernt
|
||||||
|
Assert.NotNull(posRepo.Find(1, "b")); // b bleibt offen (nicht aufgelöst)
|
||||||
|
Assert.Single(closedRepo.Find(t => t.AccountId == 1));
|
||||||
|
Assert.Single(coreLog.Inserted); // Dual-Write ins Core-Log
|
||||||
|
Assert.Equal("ResolutionFarming", coreLog.Inserted[0].ModuleName);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Leaves_unresolved_positions_open()
|
||||||
|
{
|
||||||
|
var factory = new InMemoryContextFactory<ResolutionFarmingDbContext>(o => new ResolutionFarmingDbContext(o));
|
||||||
|
var posRepo = new EfRfPositionRepository(factory);
|
||||||
|
var closedRepo = new EfRfClosedTradeRepository(factory);
|
||||||
|
posRepo.Upsert(new RfPosition { AccountId = 1, TokenId = "a", MarketSlug = "m-a", Size = 100m, EntryPrice = 0.95m, AmountUsd = 95m });
|
||||||
|
|
||||||
|
var svc = new FarmingResolutionMonitorService(new TradingState(), posRepo, closedRepo,
|
||||||
|
new FakeResolution(new()), new FakeCoreTradeLogRepository(), new TerminalLogger());
|
||||||
|
|
||||||
|
var closed = await svc.CheckAndCloseAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Empty(closed);
|
||||||
|
Assert.NotNull(posRepo.Find(1, "a"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System;
|
||||||
|
using PolyTrader.Modules.ResolutionFarming.Logic;
|
||||||
|
using PolyTrader.Modules.ResolutionFarming.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PolyTrader.Tests
|
||||||
|
{
|
||||||
|
/// <summary>Sicherheitsnetz für den reinen Positions-Abschluss bei Auflösung (PnL, Fees, Redeem-Status).</summary>
|
||||||
|
public class FarmingResolutionTests
|
||||||
|
{
|
||||||
|
private static RfPosition Pos(int feeBps = 0) => new()
|
||||||
|
{
|
||||||
|
AccountId = 1, TokenId = "a", MarketQuestion = "Q", Size = 100m, EntryPrice = 0.95m,
|
||||||
|
AmountUsd = 95m, EntryFeeBps = feeBps, IsDemo = true, OpenedAt = DateTime.UtcNow.AddHours(-5)
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Winner_maker_pays_out_one_per_share()
|
||||||
|
{
|
||||||
|
var t = FarmingResolution.BuildClosedTrade(Pos(), isWinner: true, DateTime.UtcNow);
|
||||||
|
Assert.Equal(1.0m, t.ExitPrice);
|
||||||
|
Assert.Equal(5m, t.RealizedPnl); // 100 - 95 - 0
|
||||||
|
Assert.Equal(0m, t.TotalFees);
|
||||||
|
Assert.Equal("Pending", t.RedeemStatus); // Gewinner müssen redeemt werden
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Loser_loses_entry_cost()
|
||||||
|
{
|
||||||
|
var t = FarmingResolution.BuildClosedTrade(Pos(), isWinner: false, DateTime.UtcNow);
|
||||||
|
Assert.Equal(0.0m, t.ExitPrice);
|
||||||
|
Assert.Equal(-95m, t.RealizedPnl);
|
||||||
|
Assert.Equal("None", t.RedeemStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Taker_entry_fee_reduces_winner_pnl()
|
||||||
|
{
|
||||||
|
var t = FarmingResolution.BuildClosedTrade(Pos(feeBps: 100), isWinner: true, DateTime.UtcNow);
|
||||||
|
Assert.Equal(4.05m, t.RealizedPnl); // 100 - 95 - 0.95
|
||||||
|
Assert.Equal(0.95m, t.TotalFees);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user