Phase 0: Alt-Backups (.bak) und tote Stub-Klassen entfernt

- 11 *.bak-Dateien entfernt (weiterhin im Baseline-Commit rekonstruierbar)
- Tote Stubs entfernt: services/database.cs, services/settings.cs,
  polymarket/{blockchainlistener,polymarket-api,trademanager}.cs
  (leere, unreferenzierte Klassen)
- Debug-Build verifiziert: 0 Fehler

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
bergm
2026-07-01 13:20:59 +02:00
co-authored by Claude Opus 4.8
parent 475d396f80
commit f76ad737d6
16 changed files with 0 additions and 5997 deletions
-32
View File
@@ -1,32 +0,0 @@
namespace PolyTraderSharp.Models
{
public class ClosedTrade
{
public int TradeId { get; set; }
public int AccountId { get; set; }
public int SourceTraderId { get; set; }
public bool IsDemo { get; set; }
public string TokenId { get; set; } = string.Empty;
public string MarketSlug { get; set; } = string.Empty;
public string MarketQuestion { get; set; } = string.Empty;
public string Outcome { get; set; } = string.Empty;
public string Side { get; set; } = string.Empty;
public decimal EntryPrice { get; set; }
public decimal ExitPrice { get; set; }
public decimal Size { get; set; }
public decimal RealizedPnl { get; set; }
public decimal PnlPercent { get; set; }
public decimal TotalFees { get; set; }
public DateTime OpenedAt { get; set; }
public DateTime ClosedAt { get; set; }
public string ExitReason { get; set; } = string.Empty;
}
public class ClosedTradeRow : ClosedTrade
{
public string AccountName { get; set; } = string.Empty;
}
}
-14
View File
@@ -1,14 +0,0 @@
using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace PolyTraderSharp.polymarket
{
internal class BlockchainListener
{
}
}
-14
View File
@@ -1,14 +0,0 @@
using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace PolyTraderSharp.polymarket
{
internal class polymarket_api
{
}
}
-14
View File
@@ -1,14 +0,0 @@
using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace PolyTraderSharp.polymarket
{
internal class TradeManager
{
}
}
-479
View File
@@ -1,479 +0,0 @@
using System;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PolyTraderSharp.Models;
using System.Collections.Concurrent;
using System.Linq;
namespace PolyTraderSharp.Services
{
public class CopyTradingEngine : BackgroundService
{
private readonly TradingState _state;
private readonly ChannelReader<CopySignal> _signalReader;
private readonly ChannelWriter<ClosedTrade> _closedTradeWriter;
private readonly TerminalLogger _logger;
private readonly PolymarketClobClient _clob;
private readonly PolymarketApiService _api;
private readonly LiteDB.ILiteDatabase _db;
private static readonly ConcurrentDictionary<string, MarketData> _marketCache = new(StringComparer.OrdinalIgnoreCase);
public CopyTradingEngine(
TradingState state,
ChannelReader<CopySignal> signalReader,
ChannelWriter<ClosedTrade> closedTradeWriter,
TerminalLogger logger,
PolymarketClobClient clob,
PolymarketApiService api,
LiteDB.ILiteDatabase db = null)
{
_state = state;
_signalReader = signalReader;
_closedTradeWriter = closedTradeWriter;
_logger = logger;
_clob = clob;
_api = api;
_db = db;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.Info("CopyTradingEngine Channel Listener started (Concurrent).");
var semaphore = new SemaphoreSlim(15, 15);
await foreach (var signal in _signalReader.ReadAllAsync(stoppingToken))
{
await semaphore.WaitAsync(stoppingToken);
_ = Task.Run(async () =>
{
try
{
await ProcessSignalAsync(signal);
}
catch (Exception ex)
{
_logger.Error($"Absturz im SignalProcessor: {ex.Message}");
}
finally
{
semaphore.Release();
}
}, stoppingToken);
}
}
private async Task ProcessSignalAsync(CopySignal signal)
{
if (_state.GlobalTradingPaused)
{
return;
}
// Internal System Signal (e.g. Demo Auto-Close)
if (signal.TraderId == 0)
{
var sysaccountTasks = new List<Task>();
foreach (var account in _state.Accounts.Values.Where(a => a.IsDemo && a.IsActive))
{
if (account.OpenPositions.ContainsKey(signal.TokenId))
{
sysaccountTasks.Add(ProcessAccountOrderAsync(account, null, signal));
}
}
await Task.WhenAll(sysaccountTasks);
return;
}
if (!_state.Traders.TryGetValue(signal.TraderId, out var trader) || !trader.IsActive)
return;
var accountTasks = new List<Task>();
foreach (var accountId in trader.AssignedAccountIds)
{
if (!_state.Accounts.TryGetValue(accountId, out var account) || !account.IsActive)
continue;
accountTasks.Add(ProcessAccountOrderAsync(account, trader, signal));
}
await Task.WhenAll(accountTasks);
}
private async Task ProcessAccountOrderAsync(AccountState account, TrackedTrader trader, CopySignal signal)
{
var mode = account.IsDemo ? _state.DemoTradingMode : _state.LiveTradingMode;
if (mode == TradingMode.Inactive)
return;
// Restrict BUY operations if mode is SellOnly
if (mode == TradingMode.SellOnly && signal.Side == "BUY")
return;
string shareType = string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome;
// ==========================================
// PRE-FLIGHT RISK CHECKS (Before DB/API!)
// ==========================================
decimal exactShares = 0m;
decimal exactUsdc = 0m;
decimal orderPrice = signal.Price;
if (signal.Side == "BUY")
{
if (signal.Price > account.MaxBuyPrice && account.TotalBalance >= 500m)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Preis (${signal.Price:F3}) übersteigt das MaxBuy Limit (${account.MaxBuyPrice:F3})");
return;
}
decimal investedInMarket = account.OpenPositions.TryGetValue(signal.TokenId, out var ep) ? ep.AmountUsd : 0m;
decimal minTrade = 1.0m;
decimal maxAllowed = account.TotalBalance * (account.MaxTradePercent / 100.0m);
// Low Balance Bypass (Stufen-System)
// Erhöht auf 1.20m um Puffer für das API Min-Limit von $1.00 zu gewährleisten
if (account.TotalBalance < 150m) maxAllowed = Math.Min(1.20m, Math.Max(account.AvailableBalance, 0m));
else if (account.TotalBalance < 500m) maxAllowed = Math.Min(3.0m, Math.Max(account.AvailableBalance, 0m));
decimal maxAmountToBuy = maxAllowed - investedInMarket;
decimal investedInMaster = account.OpenPositions.Values.Where(p => p.SourceTraderId == trader.Id).Sum(p => (decimal)p.AmountUsd);
decimal maxAllowedPerMaster = account.TotalBalance * (account.PerMasterLimit / 100.0m);
if ((investedInMaster + maxAmountToBuy) > maxAllowedPerMaster)
{
decimal pctInvested = account.TotalBalance > 0 ? (investedInMaster / account.TotalBalance) * 100m : 0m;
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: PerMasterLimit ({account.PerMasterLimit:F1}%) erreicht. Bisher investiert in '{trader.DisplayName}': ${investedInMaster:F2} ({pctInvested:F1}%).");
return;
}
if (maxAmountToBuy < minTrade)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kauflimit (${maxAllowed:F2}) bereits in Markt investiert (${investedInMarket:F2}). Rest: ${maxAmountToBuy:F2} < MinTrade (${minTrade:F2})");
return;
}
if (maxAmountToBuy > account.AvailableBalance)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kontostand (${account.AvailableBalance:F2}) nicht ausreichend für errechnetes Size (${maxAmountToBuy:F2})");
return;
}
decimal desiredLimit = signal.Price * 1.05m;
orderPrice = Math.Min(desiredLimit, account.MaxBuyPrice);
if (orderPrice > 0.99m) orderPrice = 0.99m;
var exact = PolymarketClobClient.CalculateExactOrderAmounts(maxAmountToBuy, orderPrice, orderPrice, "BUY");
if (exact.shares <= 0 || exact.usdc > account.AvailableBalance)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" +
$" Begründung: Mathematisch unmöglicher Trade ({exact.shares} Shares für ${exact.usdc:F2}). Kontostand (${account.AvailableBalance:F2}) reicht für Minimum nicht aus.");
return;
}
exactShares = exact.shares;
exactUsdc = exact.usdc;
}
else if (signal.Side == "SELL")
{
// PRE-FLIGHT SELL Check: Exists in portfolio?
// Allow fallback matching by slug and outcome if tokenId is slightly off or missing
var inPortfolio = account.OpenPositions.Values.FirstOrDefault(p => p.TokenId == signal.TokenId || (p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome));
if (inPortfolio == null)
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert).");
return;
}
}
// ==========================================
// EXPENSIVE DB/API MARKET LOOKUP
// ==========================================
bool isNegRisk = false;
if (_marketCache.TryGetValue(signal.TokenId, out var cachedData))
{
if (!string.IsNullOrEmpty(cachedData.Slug)) signal.MarketSlug = cachedData.Slug;
if (!string.IsNullOrEmpty(cachedData.Question)) signal.MarketQuestion = cachedData.Question;
if (cachedData.EndDate.HasValue) signal.EndDate = cachedData.EndDate;
isNegRisk = cachedData.NegRisk;
}
else if (_db != null)
{
try
{
var marketColl = _db.GetCollection<MarketData>("markets");
var marketData = marketColl.Find(x => x.ClobTokenIds != null && x.ClobTokenIds.Contains(signal.TokenId)).FirstOrDefault();
if (marketData == null && !string.IsNullOrEmpty(signal.TokenId))
{
var fetchedMarket = await _api.GetMarketByTokenIdAsync(signal.TokenId);
if (fetchedMarket != null) { marketColl.Upsert(fetchedMarket); marketData = fetchedMarket; }
}
if (marketData == null && !string.IsNullOrEmpty(signal.MarketSlug) && !signal.MarketSlug.StartsWith("0x"))
{
var fetchedMarkets = await _api.GetMarketsByEventSlugAsync(signal.MarketSlug);
foreach (var fetched in fetchedMarkets) {
marketColl.Upsert(fetched);
if (fetched.ClobTokenIds != null && fetched.ClobTokenIds.Contains(signal.TokenId)) marketData = fetched;
}
}
if (marketData != null)
{
if (!string.IsNullOrEmpty(marketData.Slug)) signal.MarketSlug = marketData.Slug;
if (!string.IsNullOrEmpty(marketData.Question)) signal.MarketQuestion = marketData.Question;
if (marketData.EndDate.HasValue) signal.EndDate = marketData.EndDate;
isNegRisk = marketData.NegRisk;
// Add to Cache for fast lookup
_marketCache[signal.TokenId] = marketData;
}
}
catch (Exception ex)
{
_logger.Warning($"Fehler beim Abrufen von MarketData für Token {signal.TokenId}: {ex.Message}");
}
}
// If BUY -> Invest
if (signal.Side == "BUY")
{
if (account.IsDemo)
{
var pos = new Position
{
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug,
SourceTraderId = trader.Id,
SourceTraderName = trader.DisplayName,
SourceTraderAddress = trader.WalletAddress,
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = "BUY",
EntryPrice = orderPrice,
Size = exactShares,
AmountUsd = exactUsdc,
ExpiryDate = signal.EndDate ?? DateTime.UtcNow.AddDays(14)
};
_state.TotalCopyTrades++;
var finalPos = account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) =>
{
old.Size += pos.Size;
old.AmountUsd += pos.AmountUsd;
old.EntryPrice = old.AmountUsd / old.Size; // weighted average
return old;
});
if (_db != null) _db.GetCollection<Position>($"demo_positions_{account.AccountId}").Upsert(finalPos);
account.UpdateBalance(account.AvailableBalance - exactUsdc);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
_logger.Trade($"✅ [DEMO AUSGEFÜHRT]\n" +
$" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" BUY: {exactShares:F4} Shares [{shareType}] @ ${orderPrice:F3} (Gesamt: ${exactUsdc:F2})");
}
else
{
_logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET BUY an Polymarket CTF-Router...\n" +
$" Account: {account.Name}\n" +
$" Limit: ${orderPrice:F3} (Target: {signal.Price:F3} + 5%)");
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, exactUsdc, orderPrice, "MARKET", _state.DebugOrderPayloadLog, isNegRisk);
if (result == "OK")
{
var pos = new Position
{
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug,
SourceTraderId = trader.Id,
SourceTraderName = trader.DisplayName,
SourceTraderAddress = trader.WalletAddress,
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = "BUY",
EntryPrice = orderPrice, // Real execution price will update on next SyncOpenPositions poll
Size = exactShares,
AmountUsd = exactUsdc,
ExpiryDate = signal.EndDate ?? DateTime.UtcNow.AddDays(14)
};
_state.TotalCopyTrades++;
account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) =>
{
old.Size += pos.Size;
old.AmountUsd += pos.AmountUsd;
old.EntryPrice = old.AmountUsd / old.Size;
return old;
});
account.UpdateBalance(account.AvailableBalance - exactUsdc);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
if (_db != null)
{
var liveCol = _db.GetCollection<Position>($"open_positions_{account.AccountId}");
if (account.OpenPositions.TryGetValue(signal.TokenId, out var savedPos))
{
liveCol.Upsert(savedPos);
}
}
}
}
}
// If SELL -> Divest
else if (signal.Side == "SELL")
{
bool removed = account.OpenPositions.TryRemove(signal.TokenId, out var openPos);
if (!removed && !string.IsNullOrEmpty(signal.MarketSlug))
{
var altPos = account.OpenPositions.Values.FirstOrDefault(p => p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome);
if (altPos != null)
{
removed = account.OpenPositions.TryRemove(altPos.TokenId, out openPos);
if (removed)
{
_logger.Info($"Fallback: Position für SELL über Slug+Outcome gefunden ({altPos.TokenId}) statt TokenId ({signal.TokenId})");
signal.TokenId = altPos.TokenId; // Fix for further processing
}
}
}
if (removed)
{
if (account.IsDemo)
{
if (_db != null) _db.GetCollection<Position>($"demo_positions_{account.AccountId}").Delete(signal.TokenId);
decimal exitUsd = openPos.Size * signal.Price;
decimal realizedPnl = exitUsd - openPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
account.UpdateBalance(account.AvailableBalance + exitUsd);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
var ct = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = account.AccountId,
SourceTraderId = signal.TraderId,
IsDemo = account.IsDemo,
MarketSlug = signal.MarketSlug,
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = signal.Side,
EntryPrice = openPos.EntryPrice,
ExitPrice = signal.Price,
Size = openPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = openPos.AmountUsd > 0 ? (realizedPnl / openPos.AmountUsd * 100m) : 0m,
OpenedAt = openPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = signal.Reason
};
_closedTradeWriter.TryWrite(ct);
_logger.Trade($"✅ [DEMO GESCHLOSSEN]\n" +
$" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" SELL: {openPos.Size:F2} Shares [{shareType}] @ ${signal.Price:F3} (Gewinn: ${realizedPnl:F2})");
}
else
{
decimal sellLimit = 0.01m; // Slippage Limit (Min $0.01/share)
decimal maxInvest = openPos.Size * sellLimit;
var exact = PolymarketClobClient.CalculateExactOrderAmounts(maxInvest, sellLimit, sellLimit, "SELL", "MARKET");
if (exact.shares <= 0)
{
_logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" +
$" Konto: {account.Name}\n" +
$" Grund: Mathematical Order Size Error (Dust Token).");
account.OpenPositions.TryAdd(signal.TokenId, openPos);
return;
}
_logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET SELL an Polymarket CTF-Router...\n" +
$" Account: {account.Name}\n" +
$" Typ: MARKET Order");
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, maxInvest, sellLimit, "MARKET", _state.DebugOrderPayloadLog, isNegRisk);
if (result == "OK")
{
// Simulate fill at expected price for immediate UI accuracy
// (Exact executed amounts will auto-correct on next SyncOpenPositions poll)
decimal exitUsd = exact.shares * signal.Price;
decimal realizedPnl = exitUsd - openPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
account.UpdateBalance(account.AvailableBalance + exitUsd);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
var ct = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = account.AccountId,
SourceTraderId = signal.TraderId,
IsDemo = false,
MarketSlug = signal.MarketSlug,
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = signal.Side,
EntryPrice = openPos.EntryPrice,
ExitPrice = signal.Price,
Size = openPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = openPos.AmountUsd > 0 ? (realizedPnl / openPos.AmountUsd * 100m) : 0m,
OpenedAt = openPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = signal.Reason
};
_closedTradeWriter.TryWrite(ct);
_logger.Trade($"✅ [LIVE GESCHLOSSEN] - {account.Name} - Gewinne/Verluste in Kürze im API Sync sichtbar.");
}
else
{
// Call failed, log it so the user knows Sells are being attempted but failing.
_logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" +
$" Konto: {account.Name}\n" +
$" Grund: {result}\n" +
$" Aktion: Position bleibt vorerst im Portfolio erhalten.");
// Reverse the TryRemove if it failed, so the next poll can try again
account.OpenPositions.TryAdd(signal.TokenId, openPos);
}
}
}
else
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert).");
}
}
}
}
}
-588
View File
@@ -1,588 +0,0 @@
using System;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PolyTraderSharp.Models;
using System.Collections.Concurrent;
using System.Linq;
namespace PolyTraderSharp.Services
{
public class CopyTradingEngine : BackgroundService
{
private readonly TradingState _state;
private readonly ChannelReader<CopySignal> _signalReader;
private readonly ChannelWriter<ClosedTrade> _closedTradeWriter;
private readonly TerminalLogger _logger;
private readonly PolymarketClobClient _clob;
private readonly PolymarketApiService _api;
private readonly LiteDB.ILiteDatabase? _db;
private static readonly ConcurrentDictionary<string, MarketData> _marketCache = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<int, SemaphoreSlim> _accountSemaphores = new();
public CopyTradingEngine(
TradingState state,
ChannelReader<CopySignal> signalReader,
ChannelWriter<ClosedTrade> closedTradeWriter,
TerminalLogger logger,
PolymarketClobClient clob,
PolymarketApiService api,
LiteDB.ILiteDatabase? db = null)
{
_state = state;
_signalReader = signalReader;
_closedTradeWriter = closedTradeWriter;
_logger = logger;
_clob = clob;
_api = api;
_db = db;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.Info("CopyTradingEngine Channel Listener started (Concurrent).");
var semaphore = new SemaphoreSlim(15, 15);
await foreach (var signal in _signalReader.ReadAllAsync(stoppingToken))
{
await semaphore.WaitAsync(stoppingToken);
_ = Task.Run(async () =>
{
try
{
await ProcessSignalAsync(signal);
}
catch (Exception ex)
{
_logger.Error($"Absturz im SignalProcessor: {ex.Message}");
}
finally
{
semaphore.Release();
}
}, stoppingToken);
}
}
private async Task ProcessSignalAsync(CopySignal signal)
{
if (_state.GlobalTradingPaused)
{
return;
}
// Internal System Signal (e.g. Demo Auto-Close)
if (signal.TraderId == 0)
{
var sysaccountTasks = new List<Task>();
foreach (var account in _state.Accounts.Values.Where(a => a.IsDemo && a.IsActive))
{
if (account.OpenPositions.ContainsKey(signal.TokenId))
{
sysaccountTasks.Add(ProcessAccountOrderAsync(account, null, signal));
}
}
await Task.WhenAll(sysaccountTasks);
return;
}
if (!_state.Traders.TryGetValue(signal.TraderId, out var trader) || !trader.IsActive)
return;
// --- Pre-Fetch EndDate für Time Limits ---
if (signal.Side == "BUY" && !signal.EndDate.HasValue && !string.IsNullOrEmpty(signal.TokenId))
{
if (_marketCache.TryGetValue(signal.TokenId, out var cachedData) && cachedData.EndDate.HasValue)
{
signal.EndDate = cachedData.EndDate;
}
else if (_api != null)
{
try
{
var fetchedMarket = await _api.GetMarketByTokenIdAsync(signal.TokenId);
if (fetchedMarket != null)
{
if (fetchedMarket.EndDate.HasValue) signal.EndDate = fetchedMarket.EndDate;
_marketCache[signal.TokenId] = fetchedMarket;
if (_db != null) _db.GetCollection<MarketData>("markets").Upsert(fetchedMarket);
}
}
catch (Exception ex)
{
_logger.Warning($"Fehler beim Pre-Fetch MarketData: {ex.Message}");
}
}
}
// -----------------------------------------
var accountTasks = new List<Task>();
foreach (var accountId in trader.AssignedAccountIds)
{
if (!_state.Accounts.TryGetValue(accountId, out var account) || !account.IsActive)
continue;
accountTasks.Add(ProcessAccountOrderAsync(account, trader, signal));
}
await Task.WhenAll(accountTasks);
}
private async Task ProcessAccountOrderAsync(AccountState account, TrackedTrader? trader, CopySignal signal)
{
var mode = account.IsDemo ? _state.DemoTradingMode : _state.LiveTradingMode;
if (mode == TradingMode.Inactive)
return;
// Restrict BUY operations if mode is SellOnly
if (mode == TradingMode.SellOnly && signal.Side == "BUY")
return;
string shareType = string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome;
var accountSemaphore = _accountSemaphores.GetOrAdd(account.AccountId, _ => new SemaphoreSlim(1, 1));
await accountSemaphore.WaitAsync();
try
{
// ==========================================
// OPEN ORDER CLEANUP (LIVE ACCOUNTS ONLY)
// ==========================================
// Wenn ein neues Signal für diesen Markt reinkommt, prüfen wir auf veraltete offene Orders.
// Identische Preise bleiben bestehen. Abweichende verhindern ungültiges Blockieren von Funds.
if (!account.IsDemo && !string.IsNullOrEmpty(signal.TokenId))
{
await _clob.CancelConflictingOrdersAsync(account, signal.TokenId, signal.Price, signal.Side);
}
if (_marketCache.TryGetValue(signal.TokenId, out var fastCachedData))
{
if (fastCachedData.EndDate.HasValue) signal.EndDate = fastCachedData.EndDate;
}
// ==========================================
// PRE-FLIGHT RISK CHECKS (Before DB/API!)
// ==========================================
decimal exactShares = 0m;
decimal exactUsdc = 0m;
decimal orderPrice = signal.Price;
if (signal.Side == "BUY")
{
if (signal.Price > account.MaxBuyPrice && account.TotalBalance >= 500m)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Preis (${signal.Price:F3}) übersteigt das MaxBuy Limit (${account.MaxBuyPrice:F3})");
return;
}
decimal investedInMarket = account.OpenPositions.TryGetValue(signal.TokenId, out var ep) ? ep.AmountUsd : 0m;
decimal minTrade = 1.0m;
decimal maxAllowed = account.TotalBalance * (account.PerMarketLimit / 100.0m);
// Low Balance Bypass (Stufen-System) ONLY IF NOT YET INVESTED
if (investedInMarket == 0)
{
if (account.TotalBalance < 150m) maxAllowed = Math.Min(1.20m, Math.Max(account.AvailableBalance, 0m));
else if (account.TotalBalance < 500m) maxAllowed = Math.Min(3.0m, Math.Max(account.AvailableBalance, 0m));
}
decimal maxAmountToBuy = maxAllowed - investedInMarket;
decimal investedInMaster = trader != null ? account.OpenPositions.Values.Where(p => p.SourceTraderId == trader.Id).Sum(p => (decimal)p.AmountUsd) : 0m;
decimal maxAllowedPerMaster = account.TotalBalance * (account.PerMasterLimit / 100.0m);
if (trader != null && (investedInMaster + maxAmountToBuy) > maxAllowedPerMaster)
{
decimal pctInvested = account.TotalBalance > 0 ? (investedInMaster / account.TotalBalance) * 100m : 0m;
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: PerMasterLimit ({account.PerMasterLimit:F1}%) erreicht. Bisher investiert in '{trader.DisplayName}': ${investedInMaster:F2} ({pctInvested:F1}%).");
return;
}
// Time Limit Restriktion
double hoursLeft = signal.EndDate.HasValue ? (signal.EndDate.Value - DateTime.UtcNow).TotalHours : 999999;
decimal applicableTimeLimitPct;
decimal investedInTimeframe = 0m;
string timeframeLabel = "";
var openVals = account.OpenPositions.Values;
if (hoursLeft < 6)
{
applicableTimeLimitPct = account.perMaxTime6h;
timeframeLabel = "< 6h";
investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 6).Sum(p => (decimal)p.AmountUsd);
}
else if (hoursLeft < 24)
{
applicableTimeLimitPct = account.perMaxTime24h;
timeframeLabel = "< 24h";
investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 6 && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 24).Sum(p => (decimal)p.AmountUsd);
}
else if (hoursLeft < 72)
{
applicableTimeLimitPct = account.perMaxTime72h;
timeframeLabel = "< 72h";
investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 24 && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 72).Sum(p => (decimal)p.AmountUsd);
}
else
{
applicableTimeLimitPct = account.perMaxTimeNone;
timeframeLabel = "> 72h";
investedInTimeframe = openVals.Where(p => !p.ExpiryDate.HasValue || (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 72).Sum(p => (decimal)p.AmountUsd);
}
decimal maxAllowedTimeframe = account.TotalBalance * (applicableTimeLimitPct / 100.0m);
if ((investedInTimeframe + maxAmountToBuy) > maxAllowedTimeframe)
{
decimal remainingForTimeframe = maxAllowedTimeframe - investedInTimeframe;
if (remainingForTimeframe < minTrade)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: TimeLimit '{timeframeLabel}' ({applicableTimeLimitPct:F1}%) erreicht. Bisher investiert: ${investedInTimeframe:F2} / max. ${maxAllowedTimeframe:F2}");
return;
}
else
{
maxAmountToBuy = remainingForTimeframe;
}
}
if (maxAmountToBuy < minTrade)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kauflimit (${maxAllowed:F2}) bereits in Markt investiert (${investedInMarket:F2}). Rest: ${maxAmountToBuy:F2} < MinTrade (${minTrade:F2})");
return;
}
if (maxAmountToBuy > account.AvailableBalance)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kontostand (${account.AvailableBalance:F2}) nicht ausreichend für errechnetes Size (${maxAmountToBuy:F2})");
return;
}
decimal desiredLimit = signal.Price * 1.05m;
orderPrice = Math.Min(desiredLimit, account.MaxBuyPrice);
if (orderPrice > 0.99m) orderPrice = 0.99m;
var exact = PolymarketClobClient.CalculateExactOrderAmounts(maxAmountToBuy, orderPrice, orderPrice, "BUY");
if (exact.shares <= 0 || exact.usdc > account.AvailableBalance)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" +
$" Begründung: Mathematisch unmöglicher Trade ({exact.shares} Shares für ${exact.usdc:F2}). Kontostand (${account.AvailableBalance:F2}) reicht für Minimum nicht aus.");
return;
}
exactShares = exact.shares;
exactUsdc = exact.usdc;
}
else if (signal.Side == "SELL")
{
// PRE-FLIGHT SELL Check: Exists in portfolio?
// Allow fallback matching by slug and outcome if tokenId is slightly off or missing
var inPortfolio = account.OpenPositions.Values.FirstOrDefault(p => p.TokenId == signal.TokenId || (p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome));
if (inPortfolio == null)
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert).");
return;
}
}
// ==========================================
// EXPENSIVE DB/API MARKET LOOKUP
// ==========================================
bool isNegRisk = false;
if (_marketCache.TryGetValue(signal.TokenId, out var cachedData))
{
if (!string.IsNullOrEmpty(cachedData.Slug)) signal.MarketSlug = cachedData.Slug;
if (!string.IsNullOrEmpty(cachedData.Question)) signal.MarketQuestion = cachedData.Question;
if (cachedData.EndDate.HasValue) signal.EndDate = cachedData.EndDate;
isNegRisk = cachedData.NegRisk;
}
else if (_db != null)
{
try
{
var marketColl = _db.GetCollection<MarketData>("markets");
var marketData = marketColl.Find(x => x.ClobTokenIds != null && x.ClobTokenIds.Contains(signal.TokenId)).FirstOrDefault();
if (marketData == null && !string.IsNullOrEmpty(signal.TokenId))
{
var fetchedMarket = await _api.GetMarketByTokenIdAsync(signal.TokenId);
if (fetchedMarket != null) { marketColl.Upsert(fetchedMarket); marketData = fetchedMarket; }
}
if (marketData == null && !string.IsNullOrEmpty(signal.MarketSlug) && !signal.MarketSlug.StartsWith("0x"))
{
var fetchedMarkets = await _api.GetMarketsByEventSlugAsync(signal.MarketSlug);
foreach (var fetched in fetchedMarkets) {
marketColl.Upsert(fetched);
if (fetched.ClobTokenIds != null && fetched.ClobTokenIds.Contains(signal.TokenId)) marketData = fetched;
}
}
if (marketData != null)
{
if (!string.IsNullOrEmpty(marketData.Slug)) signal.MarketSlug = marketData.Slug;
if (!string.IsNullOrEmpty(marketData.Question)) signal.MarketQuestion = marketData.Question;
if (marketData.EndDate.HasValue) signal.EndDate = marketData.EndDate;
isNegRisk = marketData.NegRisk;
// Add to Cache for fast lookup
_marketCache[signal.TokenId] = marketData;
}
}
catch (Exception ex)
{
_logger.Warning($"Fehler beim Abrufen von MarketData für Token {signal.TokenId}: {ex.Message}");
}
}
// If BUY -> Invest
if (signal.Side == "BUY")
{
if (account.IsDemo)
{
var pos = new Position
{
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug,
SourceTraderId = trader?.Id ?? 0,
SourceTraderName = trader?.DisplayName ?? "System",
SourceTraderAddress = trader?.WalletAddress ?? "",
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = "BUY",
EntryPrice = orderPrice,
Size = exactShares,
AmountUsd = exactUsdc,
ExpiryDate = signal.EndDate ?? DateTime.UtcNow.AddDays(14)
};
_state.TotalCopyTrades++;
var finalPos = account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) =>
{
old.Size += pos.Size;
old.AmountUsd += pos.AmountUsd;
old.EntryPrice = old.AmountUsd / old.Size; // weighted average
return old;
});
if (_db != null) _db.GetCollection<Position>($"demo_positions_{account.AccountId}").Upsert(finalPos);
account.UpdateBalance(account.AvailableBalance - exactUsdc);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
_logger.Trade($"✅ [DEMO AUSGEFÜHRT]\n" +
$" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" BUY: {exactShares:F4} Shares [{shareType}] @ ${orderPrice:F3} (Gesamt: ${exactUsdc:F2})");
}
else
{
_logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET BUY an Polymarket CTF-Router...\n" +
$" Account: {account.Name}\n" +
$" Limit: ${orderPrice:F3} (Target: {signal.Price:F3} + 5%)");
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, exactUsdc, orderPrice, "MARKET", _state.DebugOrderPayloadLog, isNegRisk);
if (result == "OK")
{
var pos = new Position
{
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug,
SourceTraderId = trader?.Id ?? 0,
SourceTraderName = trader?.DisplayName ?? "System",
SourceTraderAddress = trader?.WalletAddress ?? "",
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = "BUY",
EntryPrice = orderPrice, // Real execution price will update on next SyncOpenPositions poll
Size = exactShares,
AmountUsd = exactUsdc,
ExpiryDate = signal.EndDate ?? DateTime.UtcNow.AddDays(14)
};
_state.TotalCopyTrades++;
account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) =>
{
old.Size += pos.Size;
old.AmountUsd += pos.AmountUsd;
old.EntryPrice = old.AmountUsd / old.Size;
return old;
});
account.UpdateBalance(account.AvailableBalance - exactUsdc);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
if (_db != null)
{
var liveCol = _db.GetCollection<Position>($"open_positions_{account.AccountId}");
if (account.OpenPositions.TryGetValue(signal.TokenId, out var savedPos))
{
liveCol.Upsert(savedPos);
}
}
}
}
}
// If SELL -> Divest
else if (signal.Side == "SELL")
{
bool removed = account.OpenPositions.TryRemove(signal.TokenId, out var openPos);
if (!removed && !string.IsNullOrEmpty(signal.MarketSlug))
{
var altPos = account.OpenPositions.Values.FirstOrDefault(p => p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome);
if (altPos != null)
{
removed = account.OpenPositions.TryRemove(altPos.TokenId, out openPos);
if (removed)
{
_logger.Info($"Fallback: Position für SELL über Slug+Outcome gefunden ({altPos.TokenId}) statt TokenId ({signal.TokenId})");
signal.TokenId = altPos.TokenId; // Fix for further processing
}
}
}
if (removed && openPos != null)
{
if (account.IsDemo)
{
if (_db != null) _db.GetCollection<Position>($"demo_positions_{account.AccountId}").Delete(signal.TokenId);
decimal exitUsd = openPos.Size * signal.Price;
decimal realizedPnl = exitUsd - openPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
account.UpdateBalance(account.AvailableBalance + exitUsd);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
var ct = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = account.AccountId,
SourceTraderId = signal.TraderId,
IsDemo = account.IsDemo,
MarketSlug = signal.MarketSlug,
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = signal.Side,
EntryPrice = openPos.EntryPrice,
ExitPrice = signal.Price,
Size = openPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = openPos.AmountUsd > 0 ? (realizedPnl / openPos.AmountUsd * 100m) : 0m,
OpenedAt = openPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = signal.Reason
};
_closedTradeWriter.TryWrite(ct);
_logger.Trade($"✅ [DEMO GESCHLOSSEN]\n" +
$" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" SELL: {openPos.Size:F2} Shares [{shareType}] @ ${signal.Price:F3} (Gewinn: ${realizedPnl:F2})");
}
else
{
decimal sellLimit = 0.01m; // Slippage Limit (Min $0.01/share)
decimal maxInvest = openPos.Size * sellLimit;
var exact = PolymarketClobClient.CalculateExactOrderAmounts(maxInvest, sellLimit, sellLimit, "SELL", "MARKET");
if (exact.shares <= 0)
{
_logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" +
$" Konto: {account.Name}\n" +
$" Grund: Mathematical Order Size Error (Dust Token).");
account.OpenPositions.TryAdd(signal.TokenId, openPos);
return;
}
_logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET SELL an Polymarket CTF-Router...\n" +
$" Account: {account.Name}\n" +
$" Typ: MARKET Order");
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, maxInvest, sellLimit, "MARKET", _state.DebugOrderPayloadLog, isNegRisk);
if (result == "OK")
{
// Simulate fill at expected price for immediate UI accuracy
// (Exact executed amounts will auto-correct on next SyncOpenPositions poll)
decimal exitUsd = exact.shares * signal.Price;
decimal realizedPnl = exitUsd - openPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
account.UpdateBalance(account.AvailableBalance + exitUsd);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
var ct = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = account.AccountId,
SourceTraderId = signal.TraderId,
IsDemo = false,
MarketSlug = signal.MarketSlug,
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = signal.Side,
EntryPrice = openPos.EntryPrice,
ExitPrice = signal.Price,
Size = openPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = openPos.AmountUsd > 0 ? (realizedPnl / openPos.AmountUsd * 100m) : 0m,
OpenedAt = openPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = signal.Reason
};
_closedTradeWriter.TryWrite(ct);
_logger.Trade($"✅ [LIVE GESCHLOSSEN] - {account.Name} - Gewinne/Verluste in Kürze im API Sync sichtbar.");
}
else
{
// Call failed, log it so the user knows Sells are being attempted but failing.
_logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" +
$" Konto: {account.Name}\n" +
$" Grund: {result}\n" +
$" Aktion: Position bleibt vorerst im Portfolio erhalten.");
// Reverse the TryRemove if it failed, so the next poll can try again
account.OpenPositions.TryAdd(signal.TokenId, openPos);
}
}
}
else
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert).");
}
}
}
finally
{
accountSemaphore.Release();
}
}
}
}
-602
View File
@@ -1,602 +0,0 @@
using System;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PolyTraderSharp.Models;
using System.Collections.Concurrent;
using System.Linq;
namespace PolyTraderSharp.Services
{
public class CopyTradingEngine : BackgroundService
{
private readonly TradingState _state;
private readonly ChannelReader<CopySignal> _signalReader;
private readonly ChannelWriter<ClosedTrade> _closedTradeWriter;
private readonly TerminalLogger _logger;
private readonly PolymarketClobClient _clob;
private readonly PolymarketApiService _api;
private readonly LiteDB.ILiteDatabase? _db;
private static readonly ConcurrentDictionary<string, MarketData> _marketCache = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<int, SemaphoreSlim> _accountSemaphores = new();
public CopyTradingEngine(
TradingState state,
ChannelReader<CopySignal> signalReader,
ChannelWriter<ClosedTrade> closedTradeWriter,
TerminalLogger logger,
PolymarketClobClient clob,
PolymarketApiService api,
LiteDB.ILiteDatabase? db = null)
{
_state = state;
_signalReader = signalReader;
_closedTradeWriter = closedTradeWriter;
_logger = logger;
_clob = clob;
_api = api;
_db = db;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.Info("CopyTradingEngine Channel Listener started (Concurrent).");
var semaphore = new SemaphoreSlim(15, 15);
await foreach (var signal in _signalReader.ReadAllAsync(stoppingToken))
{
await semaphore.WaitAsync(stoppingToken);
_ = Task.Run(async () =>
{
try
{
await ProcessSignalAsync(signal);
}
catch (Exception ex)
{
_logger.Error($"Absturz im SignalProcessor: {ex.Message}");
}
finally
{
semaphore.Release();
}
}, stoppingToken);
}
}
private async Task ProcessSignalAsync(CopySignal signal)
{
if (_state.GlobalTradingPaused)
{
return;
}
// Internal System Signal (e.g. Demo Auto-Close)
if (signal.TraderId == 0)
{
var sysaccountTasks = new List<Task>();
foreach (var account in _state.Accounts.Values.Where(a => a.IsDemo && a.IsActive))
{
if (account.OpenPositions.ContainsKey(signal.TokenId))
{
sysaccountTasks.Add(ProcessAccountOrderAsync(account, null, signal));
}
}
await Task.WhenAll(sysaccountTasks);
return;
}
if (!_state.Traders.TryGetValue(signal.TraderId, out var trader) || !trader.IsActive)
return;
// --- Pre-Fetch EndDate für Time Limits ---
if (signal.Side == "BUY" && !signal.EndDate.HasValue && !string.IsNullOrEmpty(signal.TokenId))
{
if (_marketCache.TryGetValue(signal.TokenId, out var cachedData) && cachedData.EndDate.HasValue)
{
signal.EndDate = cachedData.EndDate;
}
else if (_api != null)
{
try
{
var fetchedMarket = await _api.GetMarketByTokenIdAsync(signal.TokenId);
if (fetchedMarket != null)
{
if (fetchedMarket.EndDate.HasValue) signal.EndDate = fetchedMarket.EndDate;
_marketCache[signal.TokenId] = fetchedMarket;
if (_db != null) _db.GetCollection<MarketData>("markets").Upsert(fetchedMarket);
}
}
catch (Exception ex)
{
_logger.Warning($"Fehler beim Pre-Fetch MarketData: {ex.Message}");
}
}
}
// -----------------------------------------
var accountTasks = new List<Task>();
foreach (var accountId in trader.AssignedAccountIds)
{
if (!_state.Accounts.TryGetValue(accountId, out var account) || !account.IsActive)
continue;
accountTasks.Add(ProcessAccountOrderAsync(account, trader, signal));
}
await Task.WhenAll(accountTasks);
}
private async Task ProcessAccountOrderAsync(AccountState account, TrackedTrader? trader, CopySignal signal)
{
var mode = account.IsDemo ? _state.DemoTradingMode : _state.LiveTradingMode;
if (mode == TradingMode.Inactive)
return;
// Restrict BUY operations if mode is SellOnly
if (mode == TradingMode.SellOnly && signal.Side == "BUY")
return;
string shareType = string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome;
var accountSemaphore = _accountSemaphores.GetOrAdd(account.AccountId, _ => new SemaphoreSlim(1, 1));
await accountSemaphore.WaitAsync();
try
{
// ==========================================
// OPEN ORDER CLEANUP (LIVE ACCOUNTS ONLY)
// ==========================================
// Wenn ein neues Signal für diesen Markt reinkommt, prüfen wir auf veraltete offene Orders.
// Identische Preise bleiben bestehen. Abweichende verhindern ungültiges Blockieren von Funds.
if (!account.IsDemo && !string.IsNullOrEmpty(signal.TokenId))
{
await _clob.CancelConflictingOrdersAsync(account, signal.TokenId, signal.Price, signal.Side);
}
if (_marketCache.TryGetValue(signal.TokenId, out var fastCachedData))
{
if (fastCachedData.EndDate.HasValue) signal.EndDate = fastCachedData.EndDate;
}
// ==========================================
// PRE-FLIGHT RISK CHECKS (Before DB/API!)
// ==========================================
decimal exactShares = 0m;
decimal exactUsdc = 0m;
decimal orderPrice = signal.Price;
if (signal.Side == "BUY")
{
if (signal.Price > account.MaxBuyPrice && account.TotalBalance >= 500m)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Preis (${signal.Price:F3}) übersteigt das MaxBuy Limit (${account.MaxBuyPrice:F3})");
return;
}
decimal investedInMarket = account.OpenPositions.TryGetValue(signal.TokenId, out var ep) ? ep.AmountUsd : 0m;
decimal minTrade = 1.0m;
decimal maxAllowed = account.TotalBalance * (account.PerMarketLimit / 100.0m);
// Low Balance Bypass (Stufen-System) ONLY IF NOT YET INVESTED
if (investedInMarket == 0)
{
if (account.TotalBalance < 150m) maxAllowed = Math.Min(1.20m, Math.Max(account.AvailableBalance, 0m));
else if (account.TotalBalance < 500m) maxAllowed = Math.Min(3.0m, Math.Max(account.AvailableBalance, 0m));
if (_state.SixSharesMinimum && account.TotalBalance < 500m)
{
// Adjust maxAllowed to cover at least 6 shares * order limit price.
decimal desiredLimitForSix = signal.Price * 1.05m;
decimal orderPriceForSix = Math.Min(desiredLimitForSix, account.MaxBuyPrice);
if (orderPriceForSix > 0.99m) orderPriceForSix = 0.99m;
decimal costSix = 6m * orderPriceForSix;
if (costSix > maxAllowed)
{
maxAllowed = Math.Min(costSix, Math.Max(account.AvailableBalance, 0m));
}
}
}
decimal maxAmountToBuy = maxAllowed - investedInMarket;
decimal investedInMaster = trader != null ? account.OpenPositions.Values.Where(p => p.SourceTraderId == trader.Id).Sum(p => (decimal)p.AmountUsd) : 0m;
decimal maxAllowedPerMaster = account.TotalBalance * (account.PerMasterLimit / 100.0m);
if (trader != null && (investedInMaster + maxAmountToBuy) > maxAllowedPerMaster)
{
decimal pctInvested = account.TotalBalance > 0 ? (investedInMaster / account.TotalBalance) * 100m : 0m;
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: PerMasterLimit ({account.PerMasterLimit:F1}%) erreicht. Bisher investiert in '{trader.DisplayName}': ${investedInMaster:F2} ({pctInvested:F1}%).");
return;
}
// Time Limit Restriktion
double hoursLeft = signal.EndDate.HasValue ? (signal.EndDate.Value - DateTime.UtcNow).TotalHours : 999999;
decimal applicableTimeLimitPct;
decimal investedInTimeframe = 0m;
string timeframeLabel = "";
var openVals = account.OpenPositions.Values;
if (hoursLeft < 6)
{
applicableTimeLimitPct = account.perMaxTime6h;
timeframeLabel = "< 6h";
investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 6).Sum(p => (decimal)p.AmountUsd);
}
else if (hoursLeft < 24)
{
applicableTimeLimitPct = account.perMaxTime24h;
timeframeLabel = "< 24h";
investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 6 && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 24).Sum(p => (decimal)p.AmountUsd);
}
else if (hoursLeft < 72)
{
applicableTimeLimitPct = account.perMaxTime72h;
timeframeLabel = "< 72h";
investedInTimeframe = openVals.Where(p => p.ExpiryDate.HasValue && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 24 && (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours < 72).Sum(p => (decimal)p.AmountUsd);
}
else
{
applicableTimeLimitPct = account.perMaxTimeNone;
timeframeLabel = "> 72h";
investedInTimeframe = openVals.Where(p => !p.ExpiryDate.HasValue || (p.ExpiryDate.Value - DateTime.UtcNow).TotalHours >= 72).Sum(p => (decimal)p.AmountUsd);
}
decimal maxAllowedTimeframe = account.TotalBalance * (applicableTimeLimitPct / 100.0m);
if ((investedInTimeframe + maxAmountToBuy) > maxAllowedTimeframe)
{
decimal remainingForTimeframe = maxAllowedTimeframe - investedInTimeframe;
if (remainingForTimeframe < minTrade)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: TimeLimit '{timeframeLabel}' ({applicableTimeLimitPct:F1}%) erreicht. Bisher investiert: ${investedInTimeframe:F2} / max. ${maxAllowedTimeframe:F2}");
return;
}
else
{
maxAmountToBuy = remainingForTimeframe;
}
}
if (maxAmountToBuy < minTrade)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kauflimit (${maxAllowed:F2}) bereits in Markt investiert (${investedInMarket:F2}). Rest: ${maxAmountToBuy:F2} < MinTrade (${minTrade:F2})");
return;
}
if (maxAmountToBuy > account.AvailableBalance)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Kontostand (${account.AvailableBalance:F2}) nicht ausreichend für errechnetes Size (${maxAmountToBuy:F2})");
return;
}
decimal desiredLimit = signal.Price * 1.05m;
orderPrice = Math.Min(desiredLimit, account.MaxBuyPrice);
if (orderPrice > 0.99m) orderPrice = 0.99m;
var exact = PolymarketClobClient.CalculateExactOrderAmounts(maxAmountToBuy, orderPrice, orderPrice, "BUY");
if (exact.shares <= 0 || exact.usdc > account.AvailableBalance)
{
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" +
$" Begründung: Mathematisch unmöglicher Trade ({exact.shares} Shares für ${exact.usdc:F2}). Kontostand (${account.AvailableBalance:F2}) reicht für Minimum nicht aus.");
return;
}
exactShares = exact.shares;
exactUsdc = exact.usdc;
}
else if (signal.Side == "SELL")
{
// PRE-FLIGHT SELL Check: Exists in portfolio?
// Allow fallback matching by slug and outcome if tokenId is slightly off or missing
var inPortfolio = account.OpenPositions.Values.FirstOrDefault(p => p.TokenId == signal.TokenId || (p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome));
if (inPortfolio == null)
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert).");
return;
}
}
// ==========================================
// EXPENSIVE DB/API MARKET LOOKUP
// ==========================================
bool isNegRisk = false;
if (_marketCache.TryGetValue(signal.TokenId, out var cachedData))
{
if (!string.IsNullOrEmpty(cachedData.Slug)) signal.MarketSlug = cachedData.Slug;
if (!string.IsNullOrEmpty(cachedData.Question)) signal.MarketQuestion = cachedData.Question;
if (cachedData.EndDate.HasValue) signal.EndDate = cachedData.EndDate;
isNegRisk = cachedData.NegRisk;
}
else if (_db != null)
{
try
{
var marketColl = _db.GetCollection<MarketData>("markets");
var marketData = marketColl.Find(x => x.ClobTokenIds != null && x.ClobTokenIds.Contains(signal.TokenId)).FirstOrDefault();
if (marketData == null && !string.IsNullOrEmpty(signal.TokenId))
{
var fetchedMarket = await _api.GetMarketByTokenIdAsync(signal.TokenId);
if (fetchedMarket != null) { marketColl.Upsert(fetchedMarket); marketData = fetchedMarket; }
}
if (marketData == null && !string.IsNullOrEmpty(signal.MarketSlug) && !signal.MarketSlug.StartsWith("0x"))
{
var fetchedMarkets = await _api.GetMarketsByEventSlugAsync(signal.MarketSlug);
foreach (var fetched in fetchedMarkets) {
marketColl.Upsert(fetched);
if (fetched.ClobTokenIds != null && fetched.ClobTokenIds.Contains(signal.TokenId)) marketData = fetched;
}
}
if (marketData != null)
{
if (!string.IsNullOrEmpty(marketData.Slug)) signal.MarketSlug = marketData.Slug;
if (!string.IsNullOrEmpty(marketData.Question)) signal.MarketQuestion = marketData.Question;
if (marketData.EndDate.HasValue) signal.EndDate = marketData.EndDate;
isNegRisk = marketData.NegRisk;
// Add to Cache for fast lookup
_marketCache[signal.TokenId] = marketData;
}
}
catch (Exception ex)
{
_logger.Warning($"Fehler beim Abrufen von MarketData für Token {signal.TokenId}: {ex.Message}");
}
}
// If BUY -> Invest
if (signal.Side == "BUY")
{
if (account.IsDemo)
{
var pos = new Position
{
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug,
SourceTraderId = trader?.Id ?? 0,
SourceTraderName = trader?.DisplayName ?? "System",
SourceTraderAddress = trader?.WalletAddress ?? "",
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = "BUY",
EntryPrice = orderPrice,
Size = exactShares,
AmountUsd = exactUsdc,
ExpiryDate = signal.EndDate ?? DateTime.UtcNow.AddDays(14)
};
_state.TotalCopyTrades++;
var finalPos = account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) =>
{
old.Size += pos.Size;
old.AmountUsd += pos.AmountUsd;
old.EntryPrice = old.AmountUsd / old.Size; // weighted average
return old;
});
if (_db != null) _db.GetCollection<Position>($"demo_positions_{account.AccountId}").Upsert(finalPos);
account.UpdateBalance(account.AvailableBalance - exactUsdc);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
_logger.Trade($"✅ [DEMO AUSGEFÜHRT]\n" +
$" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" BUY: {exactShares:F4} Shares [{shareType}] @ ${orderPrice:F3} (Gesamt: ${exactUsdc:F2})");
}
else
{
_logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET BUY an Polymarket CTF-Router...\n" +
$" Account: {account.Name}\n" +
$" Limit: ${orderPrice:F3} (Target: {signal.Price:F3} + 5%)");
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, exactUsdc, orderPrice, "MARKET", _state.DebugOrderPayloadLog, isNegRisk);
if (result == "OK")
{
var pos = new Position
{
TokenId = signal.TokenId,
MarketSlug = signal.MarketSlug,
SourceTraderId = trader?.Id ?? 0,
SourceTraderName = trader?.DisplayName ?? "System",
SourceTraderAddress = trader?.WalletAddress ?? "",
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = "BUY",
EntryPrice = orderPrice, // Real execution price will update on next SyncOpenPositions poll
Size = exactShares,
AmountUsd = exactUsdc,
ExpiryDate = signal.EndDate ?? DateTime.UtcNow.AddDays(14)
};
_state.TotalCopyTrades++;
account.OpenPositions.AddOrUpdate(signal.TokenId, pos, (k, old) =>
{
old.Size += pos.Size;
old.AmountUsd += pos.AmountUsd;
old.EntryPrice = old.AmountUsd / old.Size;
return old;
});
account.UpdateBalance(account.AvailableBalance - exactUsdc);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
if (_db != null)
{
var liveCol = _db.GetCollection<Position>($"open_positions_{account.AccountId}");
if (account.OpenPositions.TryGetValue(signal.TokenId, out var savedPos))
{
liveCol.Upsert(savedPos);
}
}
}
}
}
// If SELL -> Divest
else if (signal.Side == "SELL")
{
bool removed = account.OpenPositions.TryRemove(signal.TokenId, out var openPos);
if (!removed && !string.IsNullOrEmpty(signal.MarketSlug))
{
var altPos = account.OpenPositions.Values.FirstOrDefault(p => p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome);
if (altPos != null)
{
removed = account.OpenPositions.TryRemove(altPos.TokenId, out openPos);
if (removed)
{
_logger.Info($"Fallback: Position für SELL über Slug+Outcome gefunden ({altPos.TokenId}) statt TokenId ({signal.TokenId})");
signal.TokenId = altPos.TokenId; // Fix for further processing
}
}
}
if (removed && openPos != null)
{
if (account.IsDemo)
{
if (_db != null) _db.GetCollection<Position>($"demo_positions_{account.AccountId}").Delete(signal.TokenId);
decimal exitUsd = openPos.Size * signal.Price;
decimal realizedPnl = exitUsd - openPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
account.UpdateBalance(account.AvailableBalance + exitUsd);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
var ct = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = account.AccountId,
SourceTraderId = signal.TraderId,
IsDemo = account.IsDemo,
MarketSlug = signal.MarketSlug,
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = signal.Side,
EntryPrice = openPos.EntryPrice,
ExitPrice = signal.Price,
Size = openPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = openPos.AmountUsd > 0 ? (realizedPnl / openPos.AmountUsd * 100m) : 0m,
OpenedAt = openPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = signal.Reason
};
_closedTradeWriter.TryWrite(ct);
_logger.Trade($"✅ [DEMO GESCHLOSSEN]\n" +
$" Konto: {account.Name}\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" SELL: {openPos.Size:F2} Shares [{shareType}] @ ${signal.Price:F3} (Gewinn: ${realizedPnl:F2})");
}
else
{
decimal sellLimit = 0.01m; // Slippage Limit (Min $0.01/share)
decimal maxInvest = openPos.Size * sellLimit;
var exact = PolymarketClobClient.CalculateExactOrderAmounts(maxInvest, sellLimit, sellLimit, "SELL", "MARKET");
if (exact.shares <= 0)
{
_logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" +
$" Konto: {account.Name}\n" +
$" Grund: Mathematical Order Size Error (Dust Token).");
account.OpenPositions.TryAdd(signal.TokenId, openPos);
return;
}
_logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET SELL an Polymarket CTF-Router...\n" +
$" Account: {account.Name}\n" +
$" Typ: MARKET Order");
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, maxInvest, sellLimit, "MARKET", _state.DebugOrderPayloadLog, isNegRisk);
if (result == "OK")
{
// Simulate fill at expected price for immediate UI accuracy
// (Exact executed amounts will auto-correct on next SyncOpenPositions poll)
decimal exitUsd = exact.shares * signal.Price;
decimal realizedPnl = exitUsd - openPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
account.UpdateBalance(account.AvailableBalance + exitUsd);
if (_db != null) _db.GetCollection<AccountState>("accounts").Upsert(account);
var ct = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = account.AccountId,
SourceTraderId = signal.TraderId,
IsDemo = false,
MarketSlug = signal.MarketSlug,
MarketQuestion = signal.MarketQuestion,
Outcome = signal.Outcome,
Side = signal.Side,
EntryPrice = openPos.EntryPrice,
ExitPrice = signal.Price,
Size = openPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = openPos.AmountUsd > 0 ? (realizedPnl / openPos.AmountUsd * 100m) : 0m,
OpenedAt = openPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = signal.Reason
};
_closedTradeWriter.TryWrite(ct);
_logger.Trade($"✅ [LIVE GESCHLOSSEN] - {account.Name} - Gewinne/Verluste in Kürze im API Sync sichtbar.");
}
else
{
// Call failed, log it so the user knows Sells are being attempted but failing.
_logger.TradeReasoning($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] fehlgeschlagen!\n" +
$" Konto: {account.Name}\n" +
$" Grund: {result}\n" +
$" Aktion: Position bleibt vorerst im Portfolio erhalten.");
// Reverse the TryRemove if it failed, so the next poll can try again
account.OpenPositions.TryAdd(signal.TokenId, openPos);
}
}
}
else
{
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
$" Konto: {account.Name}\n" +
$" Begründung: Position nicht im Portfolio gefunden (möglicherweise zuvor gefiltert).");
}
}
}
finally
{
accountSemaphore.Release();
}
}
}
}
-569
View File
@@ -1,569 +0,0 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Nethereum.Signer;
using Nethereum.Signer.EIP712;
using Nethereum.ABI.FunctionEncoding.Attributes;
using Nethereum.ABI.EIP712;
using Nethereum.Util;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
[Struct("EIP712Domain")]
public class ClobDomain
{
[Parameter("string", "name", 1)]
public string Name { get; set; }
[Parameter("string", "version", 2)]
public string Version { get; set; } = "";
[Parameter("uint256", "chainId", 3)]
public System.Numerics.BigInteger ChainId { get; set; }
}
[Struct("EIP712Domain")]
public class CtfDomain
{
[Parameter("string", "name", 1)]
public string Name { get; set; }
[Parameter("string", "version", 2)]
public string Version { get; set; }
[Parameter("uint256", "chainId", 3)]
public ulong ChainId { get; set; }
[Parameter("address", "verifyingContract", 4)]
public string VerifyingContract { get; set; }
}
[Struct("ClobAuth")]
public class ClobAuth
{
[Parameter("address", "address", 1)]
public string Address { get; set; }
[Parameter("string", "timestamp", 2)]
public string Timestamp { get; set; } = "";
[Parameter("uint256", "nonce", 3)]
public System.Numerics.BigInteger Nonce { get; set; }
[Parameter("string", "message", 4)]
public string Message { get; set; }
}
[Struct("Order")]
public class CtfOrder
{
[Parameter("uint256", "salt", 1)]
public System.Numerics.BigInteger Salt { get; set; }
[Parameter("address", "maker", 2)]
public string Maker { get; set; }
[Parameter("address", "signer", 3)]
public string Signer { get; set; }
[Parameter("address", "taker", 4)]
public string Taker { get; set; }
[Parameter("uint256", "tokenId", 5)]
public System.Numerics.BigInteger TokenId { get; set; }
[Parameter("uint256", "makerAmount", 6)]
public System.Numerics.BigInteger MakerAmount { get; set; }
[Parameter("uint256", "takerAmount", 7)]
public System.Numerics.BigInteger TakerAmount { get; set; }
[Parameter("uint256", "expiration", 8)]
public System.Numerics.BigInteger Expiration { get; set; }
[Parameter("uint256", "nonce", 9)]
public System.Numerics.BigInteger Nonce { get; set; }
[Parameter("uint256", "feeRateBps", 10)]
public System.Numerics.BigInteger FeeRateBps { get; set; }
[Parameter("uint8", "side", 11)]
public byte Side { get; set; }
[Parameter("uint8", "signatureType", 12)]
public byte SignatureType { get; set; }
}
public class PolymarketClobClient
{
private readonly HttpClient _httpClient;
private readonly TerminalLogger _logger;
private const string ClobHost = "https://clob.polymarket.com";
private const int ChainId = 137;
public PolymarketClobClient(TerminalLogger logger, HttpClient httpClient)
{
_logger = logger;
_httpClient = httpClient;
}
/// <summary>
/// Creates an HMAC signature for authenticated requests to the Polymarket CLOB.
/// </summary>
private static string GenerateHmacSignature(string secret, string timestamp, string method, string requestPath, string body = "")
{
string payload = timestamp + method + requestPath + body;
// Convert URL-Safe Base64 back to Standard Base64
string b64 = secret.Replace('-', '+').Replace('_', '/');
switch (b64.Length % 4)
{
case 2: b64 += "=="; break;
case 3: b64 += "="; break;
}
byte[] secretBytes = Convert.FromBase64String(b64);
byte[] payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(secretBytes);
byte[] hash = hmac.ComputeHash(payloadBytes);
string signature = Convert.ToBase64String(hash);
return signature.Replace('+', '-').Replace('/', '_');
}
/// <summary>
/// Derives a new Polymarket Level 2 API Key using an EIP712 Message signed by the L1 private key.
/// </summary>
public async Task<(string ApiKey, string ApiSecret, string ApiPassphrase)> DeriveApiKeyAsync(string privateKey, string walletAddress)
{
try
{
var signer = new Eip712TypedDataSigner();
var key = new EthECKey(privateKey);
string computedAddress = key.GetPublicAddress();
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var typedData = new TypedData<ClobDomain>
{
Domain = new ClobDomain
{
Name = "ClobAuthDomain",
Version = "1",
ChainId = new System.Numerics.BigInteger(ChainId)
},
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(ClobDomain), typeof(ClobAuth)),
PrimaryType = "ClobAuth"
};
var clobAuth = new ClobAuth
{
Address = computedAddress,
Timestamp = timestamp,
Nonce = new System.Numerics.BigInteger(0),
Message = "This message attests that I control the given wallet"
};
var encoder = new Nethereum.ABI.EIP712.Eip712TypedDataEncoder();
var rawData = encoder.EncodeTypedData(clobAuth, typedData);
_logger.Warning($"DEBUG_CS_RAW_DATA: {Nethereum.Hex.HexConvertors.Extensions.HexByteConvertorExtensions.ToHex(rawData)}");
string signature = signer.SignTypedDataV4(clobAuth, typedData, key);
_logger.Warning($"DEBUG_CS_SIG: {signature}");
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}/auth/derive-api-key");
request.Headers.Add("POLY_ADDRESS", computedAddress);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_NONCE", "0");
var response = await _httpClient.SendAsync(request);
// If the key has not been created yet on Polymarket, derive might fail. We then try to create it.
if (!response.IsSuccessStatusCode)
{
_logger.Warning($"Derivation failed. Attempting to CREATE new Api Key L2 instead...");
request = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}/auth/api-key");
request.Headers.Add("POLY_ADDRESS", computedAddress);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_NONCE", "0");
response = await _httpClient.SendAsync(request);
}
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
string apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? "";
string secret = doc.RootElement.GetProperty("secret").GetString() ?? "";
string passphrase = doc.RootElement.GetProperty("passphrase").GetString() ?? "";
return (apiKey, secret, passphrase);
}
else
{
string err = await response.Content.ReadAsStringAsync();
_logger.Error($"Failed to execute L1 Auth: {response.StatusCode} {err}");
}
}
catch (Exception ex)
{
_logger.Error($"DeriveApiKeyAsync Exception: {ex.Message}");
}
return (string.Empty, string.Empty, string.Empty);
}
public async Task<decimal> GetUsdcBalanceAsync(AccountState acc, bool isRetry = false)
{
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
{
_logger.Warning($"🔑 [{acc.Name}] Skipping balance fetch: ApiKey={!string.IsNullOrEmpty(acc.ApiKey)}, Secret={!string.IsNullOrEmpty(acc.ApiSecret)}, Pass={!string.IsNullOrEmpty(acc.ApiPassphrase)}, PK={!string.IsNullOrEmpty(acc.PrivateKey)}");
return 0;
}
try
{
string endpoint = "/balance-allowance";
string requestUrl = $"{endpoint}?asset_type=COLLATERAL&signature_type=2";
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
// Python SDK signs ONLY the base path, not the query params
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "GET", endpoint);
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}{requestUrl}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
_logger.Info($"💰 [{acc.Name}] Balance API Response: {jsonStr}");
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("balance", out var balProp))
{
var balanceStr = balProp.GetString();
if (decimal.TryParse(balanceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal balRaw))
{
decimal finalBal = balRaw / 1_000_000m;
_logger.Info($"💰 [{acc.Name}] Parsed Balance: {finalBal} USDC (raw: {balRaw})");
return finalBal;
}
}
_logger.Warning($"💰 [{acc.Name}] Could not parse 'balance' from response: {jsonStr}");
}
else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"🌐 [{acc.Name}] API Keys expired/invalid. Deriving new L2 Keys from PrivateKey...");
if (!isRetry && !string.IsNullOrEmpty(acc.PrivateKey) && !string.IsNullOrEmpty(acc.WalletAddress))
{
var fallbackKeyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
var newKeys = await DeriveApiKeyAsync(acc.PrivateKey, fallbackKeyObj.GetPublicAddress());
if (!string.IsNullOrEmpty(newKeys.ApiKey))
{
acc.ApiKey = newKeys.ApiKey;
acc.ApiSecret = newKeys.ApiSecret;
acc.ApiPassphrase = newKeys.ApiPassphrase;
_logger.Info($"🌐 [{acc.Name}] Successfully derived new L2 Keys! Resuming in 2.5s...");
// Await propagation of new keys inside Polymarket's Gamma backend
await Task.Delay(2500);
// Retry recursively strictly once
return await GetUsdcBalanceAsync(acc, true);
}
}
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
}
}
catch (Exception ex)
{
_logger.Error($"CLOB Balance Fetch Error: {ex.Message}");
}
return 0;
}
private static System.Numerics.BigInteger GenerateSalt()
{
// Generate a salt similar to Py Clob Client (fits safely in a standard 64-bit int / JS Number)
long t = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
int r = System.Security.Cryptography.RandomNumberGenerator.GetInt32(0, 10000);
return new System.Numerics.BigInteger(t * 10000 + r);
}
public static (decimal shares, decimal usdc, decimal makerRaw, decimal takerRaw) CalculateExactOrderAmounts(decimal investAmountUsd, decimal rawPrice, decimal limitPrice, string sideStr, string orderType = "FOK", decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
decimal tick = overrideTickSize ?? 0.001m;
decimal priceRounded = Math.Round(rawPrice / tick) * tick;
if (priceRounded < tick) priceRounded = tick;
long priceTicks = (long)Math.Round(priceRounded * 1000m);
long makerDecimals = overrideMakerDecimals ?? (sideStr.ToUpper() == "BUY" ? 2 : 4);
long takerDecimals = overrideTakerDecimals ?? (sideStr.ToUpper() == "BUY" ? 4 : 2);
long makerStepRaw = (long)Math.Pow(10, 6 - makerDecimals);
long takerStepRaw = (long)Math.Pow(10, 6 - takerDecimals);
long numerator = 1000L * takerStepRaw;
long denominator = makerStepRaw * priceTicks;
long a = numerator, b = denominator;
while (a != 0 && b != 0) { if (a > b) a %= b; else b %= a; }
long gcd = a | b;
long N = numerator / gcd;
long baseMakerRaw = N * makerStepRaw;
decimal quantumShares;
if (sideStr.ToUpper() == "BUY")
{
long baseTakerRaw = baseMakerRaw * priceTicks / 1000L;
quantumShares = baseTakerRaw / 1000000m;
}
else
{
quantumShares = baseMakerRaw / 1000000m;
}
decimal executedShares = 0;
decimal executedUsdc = 0;
decimal finalMakerAmountRaw = 0;
decimal finalTakerAmountRaw = 0;
if (sideStr.ToUpper() == "BUY")
{
decimal sharesRaw = investAmountUsd / priceRounded;
decimal takerShares = Math.Floor(sharesRaw / quantumShares) * quantumShares;
if (takerShares < quantumShares) takerShares = quantumShares;
while (takerShares * priceRounded < 1.0m || (orderType.ToUpper() != "MARKET" && takerShares < 5.0m))
{
takerShares += quantumShares;
}
finalTakerAmountRaw = Math.Round(takerShares * 1_000_000m);
finalMakerAmountRaw = Math.Round(finalTakerAmountRaw * priceRounded);
executedShares = takerShares;
executedUsdc = finalMakerAmountRaw / 1_000_000m;
}
else
{
decimal sharesRaw = investAmountUsd / limitPrice;
decimal makerShares = Math.Floor(sharesRaw / quantumShares) * quantumShares;
if (makerShares <= 0) return (-1, -1, 0, 0);
finalMakerAmountRaw = Math.Round(makerShares * 1_000_000m);
finalTakerAmountRaw = Math.Round(finalMakerAmountRaw * priceRounded);
executedShares = makerShares;
executedUsdc = finalTakerAmountRaw / 1_000_000m;
}
return (executedShares, executedUsdc, finalMakerAmountRaw, finalTakerAmountRaw);
}
/// <summary>
/// Executes a native EIP-712 signed order (default Fill-Or-Kill)
/// </summary>
public async Task<string> PlaceOrderAsync(AccountState account, string tokenId, string sideStr, decimal investAmountUsd, decimal limitPrice, string orderType = "FOK", bool debugPayloadLog = false, bool isNegRisk = false, int actualFeeBps = 0, decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
if (string.IsNullOrEmpty(account.PrivateKey) || string.IsNullOrEmpty(account.ApiKey))
return "Error: Missing API or Private Keys";
try
{
var signer = new Eip712TypedDataSigner();
var key = new EthECKey(account.PrivateKey);
var typedData = new TypedData<CtfDomain>
{
Domain = new CtfDomain
{
Name = "Polymarket CTF Exchange",
Version = "1",
ChainId = ChainId,
VerifyingContract = isNegRisk ? "0xC5d563A36AE78145C45a50134d48A1215220f80a" : "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
},
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(CtfDomain), typeof(CtfOrder)),
PrimaryType = "Order"
};
var amounts = CalculateExactOrderAmounts(investAmountUsd, limitPrice, limitPrice, sideStr, orderType, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
if (amounts.shares <= 0)
return $"Mathematical tick size error: Balance too small to meet fractional quantum limit for exact price matching";
decimal makerAmountRaw = amounts.makerRaw;
decimal takerAmountRaw = amounts.takerRaw;
System.Numerics.BigInteger parsedTokenId;
if (tokenId.StartsWith("0x") || tokenId.Any(c => "abcdefABCDEF".Contains(c)))
{
parsedTokenId = new Nethereum.Hex.HexTypes.HexBigInteger(tokenId.StartsWith("0x") ? tokenId : "0x" + tokenId).Value;
}
else
{
parsedTokenId = System.Numerics.BigInteger.Parse(tokenId);
}
var ctfOrder = new CtfOrder
{
Salt = GenerateSalt(),
Maker = account.WalletAddress,
Signer = key.GetPublicAddress(),
Taker = "0x0000000000000000000000000000000000000000",
TokenId = parsedTokenId,
MakerAmount = new System.Numerics.BigInteger(makerAmountRaw),
TakerAmount = new System.Numerics.BigInteger(takerAmountRaw),
Expiration = 0,
Nonce = 0,
FeeRateBps = new System.Numerics.BigInteger(actualFeeBps),
Side = sideStr.ToUpper() == "BUY" ? (byte)0 : (byte)1,
SignatureType = 2
};
string signature = signer.SignTypedDataV4(ctfOrder, typedData, key);
var reqBody = new
{
order = new
{
salt = (long)ctfOrder.Salt,
maker = ctfOrder.Maker.ToLower(),
signer = ctfOrder.Signer.ToLower(),
taker = ctfOrder.Taker.ToLower(),
tokenId = ctfOrder.TokenId.ToString(),
makerAmount = ctfOrder.MakerAmount.ToString(),
takerAmount = ctfOrder.TakerAmount.ToString(),
expiration = ctfOrder.Expiration.ToString(),
nonce = ctfOrder.Nonce.ToString(),
feeRateBps = ctfOrder.FeeRateBps.ToString(),
side = ctfOrder.Side == 0 ? "BUY" : "SELL",
signatureType = ctfOrder.SignatureType,
signature = signature
},
owner = account.ApiKey,
orderType = orderType
};
string jsonBody = JsonSerializer.Serialize(reqBody);
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string requestPath = "/order";
string hmacSig = GenerateHmacSignature(account.ApiSecret, timestamp, "POST", requestPath, jsonBody);
using var request = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}{requestPath}");
var keyObj = new EthECKey(account.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", account.ApiKey);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_SIGNATURE", hmacSig);
request.Headers.Add("POLY_PASSPHRASE", account.ApiPassphrase);
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
if (debugPayloadLog)
{
_logger.Debug($"[CLOB-PAYLOAD] -> {jsonBody}");
}
var response = await _httpClient.SendAsync(request);
var responseContent = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
bool isFokFail = responseContent.Contains("FOK orders are fully filled or killed");
if (isFokFail && sideStr == "BUY")
{
// Dampen FOK failed BUY logs. Usually means target price/liquidity not met for full copy size.
// We skip it silently.
return "SKIPPED_LIQUIDITY";
}
System.IO.File.WriteAllText("last_invalid_payload.json", jsonBody);
if (isFokFail && sideStr == "SELL")
{
_logger.Warning($"Liquidität für FOK SELL reicht nicht aus. (Orderbook Size limit). Rest-Shares bleiben erhalten.");
return "Nicht genügend Liquidität für vollumfänglichen Verkauf auf diesem Preisniveau (FOK).";
}
else
{
var tickMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"breaks minimum tick size rule: ([\d\.]+)");
if (tickMatch.Success && overrideTickSize == null)
{
if (decimal.TryParse(tickMatch.Groups[1].Value, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal newTickSize))
{
_logger.Info($"🔄 Automatische Anpassung an Markt Tick-Size ({newTickSize}). Order wird erneut berechnet und platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, newTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
}
var decMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"maker amount supports a max accuracy of (\d+) decimals, taker amount a max of (\d+) decimals");
if (decMatch.Success && overrideMakerDecimals == null)
{
if (int.TryParse(decMatch.Groups[1].Value, out int newMaker) && int.TryParse(decMatch.Groups[2].Value, out int newTaker))
{
_logger.Info($"🔄 Automatische Anpassung an Dezimalregeln (Maker: {newMaker}, Taker: {newTaker}). Order wird neu berechnet...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, newMaker, newTaker);
}
}
// Check if error is "invalid fee rate" -> Extract required fee -> Retry!
var match = System.Text.RegularExpressions.Regex.Match(responseContent, @"invalid fee rate \(\d+\), current market's taker fee: (\d+)");
if (match.Success && actualFeeBps == 0) // Only retry once
{
if (int.TryParse(match.Groups[1].Value, out int newFeeBps))
{
_logger.Info($"🔄 Automatische Anpassung an Taker Fee ({newFeeBps} bps). Order wird erneut platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, newFeeBps, overrideTickSize);
}
}
_logger.Error($"CLOB Order Error ({response.StatusCode}): {responseContent}");
}
return "ERROR";
}
if (response.IsSuccessStatusCode)
{
_logger.Info($"✅ Order Platzierung Erfolgreich! {sideStr} @ {limitPrice:F3}");
return "OK";
}
else
{
_logger.Error($"❌ Order Fehler: {response.StatusCode} - {responseContent}");
return responseContent;
}
}
catch (Exception ex)
{
_logger.Error($"PlaceFokOrderAsync Runtime Fehler: {ex.Message}");
return ex.Message;
}
}
}
}
-606
View File
@@ -1,606 +0,0 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Nethereum.Signer;
using Nethereum.Signer.EIP712;
using Nethereum.ABI.FunctionEncoding.Attributes;
using Nethereum.ABI.EIP712;
using Nethereum.Util;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
[Struct("EIP712Domain")]
public class ClobDomain
{
[Parameter("string", "name", 1)]
public string Name { get; set; }
[Parameter("string", "version", 2)]
public string Version { get; set; } = "";
[Parameter("uint256", "chainId", 3)]
public System.Numerics.BigInteger ChainId { get; set; }
}
[Struct("EIP712Domain")]
public class CtfDomain
{
[Parameter("string", "name", 1)]
public string Name { get; set; }
[Parameter("string", "version", 2)]
public string Version { get; set; }
[Parameter("uint256", "chainId", 3)]
public ulong ChainId { get; set; }
[Parameter("address", "verifyingContract", 4)]
public string VerifyingContract { get; set; }
}
[Struct("ClobAuth")]
public class ClobAuth
{
[Parameter("address", "address", 1)]
public string Address { get; set; }
[Parameter("string", "timestamp", 2)]
public string Timestamp { get; set; } = "";
[Parameter("uint256", "nonce", 3)]
public System.Numerics.BigInteger Nonce { get; set; }
[Parameter("string", "message", 4)]
public string Message { get; set; }
}
[Struct("Order")]
public class CtfOrder
{
[Parameter("uint256", "salt", 1)]
public System.Numerics.BigInteger Salt { get; set; }
[Parameter("address", "maker", 2)]
public string Maker { get; set; }
[Parameter("address", "signer", 3)]
public string Signer { get; set; }
[Parameter("address", "taker", 4)]
public string Taker { get; set; }
[Parameter("uint256", "tokenId", 5)]
public System.Numerics.BigInteger TokenId { get; set; }
[Parameter("uint256", "makerAmount", 6)]
public System.Numerics.BigInteger MakerAmount { get; set; }
[Parameter("uint256", "takerAmount", 7)]
public System.Numerics.BigInteger TakerAmount { get; set; }
[Parameter("uint256", "expiration", 8)]
public System.Numerics.BigInteger Expiration { get; set; }
[Parameter("uint256", "nonce", 9)]
public System.Numerics.BigInteger Nonce { get; set; }
[Parameter("uint256", "feeRateBps", 10)]
public System.Numerics.BigInteger FeeRateBps { get; set; }
[Parameter("uint8", "side", 11)]
public byte Side { get; set; }
[Parameter("uint8", "signatureType", 12)]
public byte SignatureType { get; set; }
}
public class PolymarketClobClient
{
private readonly HttpClient _httpClient;
private readonly TerminalLogger _logger;
private const string ClobHost = "https://clob.polymarket.com";
private const int ChainId = 137;
public PolymarketClobClient(TerminalLogger logger, HttpClient httpClient)
{
_logger = logger;
_httpClient = httpClient;
}
/// <summary>
/// Creates an HMAC signature for authenticated requests to the Polymarket CLOB.
/// </summary>
private static string GenerateHmacSignature(string secret, string timestamp, string method, string requestPath, string body = "")
{
string payload = timestamp + method + requestPath + body;
// Convert URL-Safe Base64 back to Standard Base64
string b64 = secret.Replace('-', '+').Replace('_', '/');
switch (b64.Length % 4)
{
case 2: b64 += "=="; break;
case 3: b64 += "="; break;
}
byte[] secretBytes = Convert.FromBase64String(b64);
byte[] payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(secretBytes);
byte[] hash = hmac.ComputeHash(payloadBytes);
string signature = Convert.ToBase64String(hash);
return signature.Replace('+', '-').Replace('/', '_');
}
/// <summary>
/// Derives a new Polymarket Level 2 API Key using an EIP712 Message signed by the L1 private key.
/// </summary>
public async Task<(string ApiKey, string ApiSecret, string ApiPassphrase)> DeriveApiKeyAsync(string privateKey, string walletAddress)
{
try
{
var signer = new Eip712TypedDataSigner();
var key = new EthECKey(privateKey);
string computedAddress = key.GetPublicAddress();
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var typedData = new TypedData<ClobDomain>
{
Domain = new ClobDomain
{
Name = "ClobAuthDomain",
Version = "1",
ChainId = new System.Numerics.BigInteger(ChainId)
},
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(ClobDomain), typeof(ClobAuth)),
PrimaryType = "ClobAuth"
};
var clobAuth = new ClobAuth
{
Address = computedAddress,
Timestamp = timestamp,
Nonce = new System.Numerics.BigInteger(0),
Message = "This message attests that I control the given wallet"
};
var encoder = new Nethereum.ABI.EIP712.Eip712TypedDataEncoder();
var rawData = encoder.EncodeTypedData(clobAuth, typedData);
_logger.Warning($"DEBUG_CS_RAW_DATA: {Nethereum.Hex.HexConvertors.Extensions.HexByteConvertorExtensions.ToHex(rawData)}");
string signature = signer.SignTypedDataV4(clobAuth, typedData, key);
_logger.Warning($"DEBUG_CS_SIG: {signature}");
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}/auth/derive-api-key");
request.Headers.Add("POLY_ADDRESS", computedAddress);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_NONCE", "0");
var response = await _httpClient.SendAsync(request);
// If the key has not been created yet on Polymarket, derive might fail. We then try to create it.
if (!response.IsSuccessStatusCode)
{
_logger.Warning($"Derivation failed. Attempting to CREATE new Api Key L2 instead...");
request = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}/auth/api-key");
request.Headers.Add("POLY_ADDRESS", computedAddress);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_NONCE", "0");
response = await _httpClient.SendAsync(request);
}
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
string apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? "";
string secret = doc.RootElement.GetProperty("secret").GetString() ?? "";
string passphrase = doc.RootElement.GetProperty("passphrase").GetString() ?? "";
return (apiKey, secret, passphrase);
}
else
{
string err = await response.Content.ReadAsStringAsync();
_logger.Error($"Failed to execute L1 Auth: {response.StatusCode} {err}");
}
}
catch (Exception ex)
{
_logger.Error($"DeriveApiKeyAsync Exception: {ex.Message}");
}
return (string.Empty, string.Empty, string.Empty);
}
public async Task<decimal> GetUsdcBalanceAsync(AccountState acc, bool isRetry = false)
{
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
{
_logger.Warning($"🔑 [{acc.Name}] Skipping balance fetch: ApiKey={!string.IsNullOrEmpty(acc.ApiKey)}, Secret={!string.IsNullOrEmpty(acc.ApiSecret)}, Pass={!string.IsNullOrEmpty(acc.ApiPassphrase)}, PK={!string.IsNullOrEmpty(acc.PrivateKey)}");
return 0;
}
try
{
string endpoint = "/balance-allowance";
string requestUrl = $"{endpoint}?asset_type=COLLATERAL&signature_type=2";
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
// Python SDK signs ONLY the base path, not the query params
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "GET", endpoint);
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}{requestUrl}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
_logger.Info($"💰 [{acc.Name}] Balance API Response: {jsonStr}");
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("balance", out var balProp))
{
var balanceStr = balProp.GetString();
if (decimal.TryParse(balanceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal balRaw))
{
decimal finalBal = balRaw / 1_000_000m;
_logger.Info($"💰 [{acc.Name}] Parsed Balance: {finalBal} USDC (raw: {balRaw})");
return finalBal;
}
}
_logger.Warning($"💰 [{acc.Name}] Could not parse 'balance' from response: {jsonStr}");
}
else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"🌐 [{acc.Name}] API Keys expired/invalid. Deriving new L2 Keys from PrivateKey...");
if (!isRetry && !string.IsNullOrEmpty(acc.PrivateKey) && !string.IsNullOrEmpty(acc.WalletAddress))
{
var fallbackKeyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
var newKeys = await DeriveApiKeyAsync(acc.PrivateKey, fallbackKeyObj.GetPublicAddress());
if (!string.IsNullOrEmpty(newKeys.ApiKey))
{
acc.ApiKey = newKeys.ApiKey;
acc.ApiSecret = newKeys.ApiSecret;
acc.ApiPassphrase = newKeys.ApiPassphrase;
_logger.Info($"🌐 [{acc.Name}] Successfully derived new L2 Keys! Resuming in 2.5s...");
// Await propagation of new keys inside Polymarket's Gamma backend
await Task.Delay(2500);
// Retry recursively strictly once
return await GetUsdcBalanceAsync(acc, true);
}
}
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
}
}
catch (Exception ex)
{
_logger.Error($"CLOB Balance Fetch Error: {ex.Message}");
}
return 0;
}
private static System.Numerics.BigInteger GenerateSalt()
{
// Generate a salt similar to Py Clob Client (fits safely in a standard 64-bit int / JS Number)
long t = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
int r = System.Security.Cryptography.RandomNumberGenerator.GetInt32(0, 10000);
return new System.Numerics.BigInteger(t * 10000 + r);
}
public static (decimal shares, decimal usdc, decimal makerRaw, decimal takerRaw) CalculateExactOrderAmounts(decimal investAmountUsd, decimal rawPrice, decimal limitPrice, string sideStr, string orderType = "FOK", decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
decimal tick = overrideTickSize ?? 0.001m;
decimal priceRounded = Math.Round(rawPrice / tick) * tick;
if (priceRounded < tick) priceRounded = tick;
long priceTicks = (long)Math.Round(priceRounded * 1000m);
long makerDecimals = overrideMakerDecimals ?? (sideStr.ToUpper() == "BUY" ? 2 : 4);
long takerDecimals = overrideTakerDecimals ?? (sideStr.ToUpper() == "BUY" ? 4 : 2);
long makerStepRaw = (long)Math.Pow(10, 6 - makerDecimals);
long takerStepRaw = (long)Math.Pow(10, 6 - takerDecimals);
long numerator = 1000L * takerStepRaw;
long denominator = makerStepRaw * priceTicks;
long a = numerator, b = denominator;
while (a != 0 && b != 0) { if (a > b) a %= b; else b %= a; }
long gcd = a | b;
long N = numerator / gcd;
long baseMakerRaw = N * makerStepRaw;
decimal quantumShares;
if (sideStr.ToUpper() == "BUY")
{
long baseTakerRaw = baseMakerRaw * priceTicks / 1000L;
quantumShares = baseTakerRaw / 1000000m;
}
else
{
quantumShares = baseMakerRaw / 1000000m;
}
decimal executedShares = 0;
decimal executedUsdc = 0;
decimal finalMakerAmountRaw = 0;
decimal finalTakerAmountRaw = 0;
if (sideStr.ToUpper() == "BUY")
{
decimal sharesRaw = investAmountUsd / priceRounded;
decimal takerShares = Math.Floor(sharesRaw / quantumShares) * quantumShares;
if (takerShares < quantumShares) takerShares = quantumShares;
while (takerShares * priceRounded < 1.0m || (orderType.ToUpper() != "MARKET" && takerShares < 5.0m))
{
takerShares += quantumShares;
}
finalTakerAmountRaw = Math.Round(takerShares * 1_000_000m);
finalMakerAmountRaw = Math.Round(finalTakerAmountRaw * priceRounded);
executedShares = takerShares;
executedUsdc = finalMakerAmountRaw / 1_000_000m;
}
else
{
decimal sharesRaw = investAmountUsd / limitPrice;
decimal makerShares = Math.Floor(sharesRaw / quantumShares) * quantumShares;
if (makerShares <= 0) return (-1, -1, 0, 0);
finalMakerAmountRaw = Math.Round(makerShares * 1_000_000m);
finalTakerAmountRaw = Math.Round(finalMakerAmountRaw * priceRounded);
executedShares = makerShares;
executedUsdc = finalTakerAmountRaw / 1_000_000m;
}
return (executedShares, executedUsdc, finalMakerAmountRaw, finalTakerAmountRaw);
}
/// <summary>
/// Executes a native EIP-712 signed order (default Fill-Or-Kill)
/// </summary>
public async Task<string> PlaceOrderAsync(AccountState account, string tokenId, string sideStr, decimal investAmountUsd, decimal limitPrice, string orderType = "FOK", bool debugPayloadLog = false, bool isNegRisk = false, int actualFeeBps = 0, decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
if (string.IsNullOrEmpty(account.PrivateKey) || string.IsNullOrEmpty(account.ApiKey))
return "Error: Missing API or Private Keys";
try
{
var signer = new Eip712TypedDataSigner();
var key = new EthECKey(account.PrivateKey);
var typedData = new TypedData<CtfDomain>
{
Domain = new CtfDomain
{
Name = "Polymarket CTF Exchange",
Version = "1",
ChainId = ChainId,
VerifyingContract = isNegRisk ? "0xC5d563A36AE78145C45a50134d48A1215220f80a" : "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
},
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(CtfDomain), typeof(CtfOrder)),
PrimaryType = "Order"
};
var amounts = CalculateExactOrderAmounts(investAmountUsd, limitPrice, limitPrice, sideStr, orderType, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
if (amounts.shares <= 0)
return $"Mathematical tick size error: Balance too small to meet fractional quantum limit for exact price matching";
decimal makerAmountRaw = amounts.makerRaw;
decimal takerAmountRaw = amounts.takerRaw;
System.Numerics.BigInteger parsedTokenId;
if (tokenId.StartsWith("0x") || tokenId.Any(c => "abcdefABCDEF".Contains(c)))
{
parsedTokenId = new Nethereum.Hex.HexTypes.HexBigInteger(tokenId.StartsWith("0x") ? tokenId : "0x" + tokenId).Value;
}
else
{
parsedTokenId = System.Numerics.BigInteger.Parse(tokenId);
}
var ctfOrder = new CtfOrder
{
Salt = GenerateSalt(),
Maker = account.WalletAddress,
Signer = key.GetPublicAddress(),
Taker = "0x0000000000000000000000000000000000000000",
TokenId = parsedTokenId,
MakerAmount = new System.Numerics.BigInteger(makerAmountRaw),
TakerAmount = new System.Numerics.BigInteger(takerAmountRaw),
Expiration = 0,
Nonce = 0,
FeeRateBps = new System.Numerics.BigInteger(actualFeeBps),
Side = sideStr.ToUpper() == "BUY" ? (byte)0 : (byte)1,
SignatureType = 2
};
string signature = signer.SignTypedDataV4(ctfOrder, typedData, key);
var reqBody = new
{
order = new
{
salt = (long)ctfOrder.Salt,
maker = ctfOrder.Maker.ToLower(),
signer = ctfOrder.Signer.ToLower(),
taker = ctfOrder.Taker.ToLower(),
tokenId = ctfOrder.TokenId.ToString(),
makerAmount = ctfOrder.MakerAmount.ToString(),
takerAmount = ctfOrder.TakerAmount.ToString(),
expiration = ctfOrder.Expiration.ToString(),
nonce = ctfOrder.Nonce.ToString(),
feeRateBps = ctfOrder.FeeRateBps.ToString(),
side = ctfOrder.Side == 0 ? "BUY" : "SELL",
signatureType = ctfOrder.SignatureType,
signature = signature
},
owner = account.ApiKey,
orderType = orderType
};
string jsonBody = JsonSerializer.Serialize(reqBody);
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string requestPath = "/order";
string hmacSig = GenerateHmacSignature(account.ApiSecret, timestamp, "POST", requestPath, jsonBody);
using var request = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}{requestPath}");
var keyObj = new EthECKey(account.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", account.ApiKey);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_SIGNATURE", hmacSig);
request.Headers.Add("POLY_PASSPHRASE", account.ApiPassphrase);
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
if (debugPayloadLog)
{
_logger.Debug($"[CLOB-PAYLOAD] -> {jsonBody}");
}
var response = await _httpClient.SendAsync(request);
var responseContent = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
bool isFokFail = responseContent.Contains("FOK orders are fully filled or killed");
if (isFokFail && sideStr == "BUY")
{
// Dampen FOK failed BUY logs. Usually means target price/liquidity not met for full copy size.
// We skip it silently.
return "SKIPPED_LIQUIDITY";
}
System.IO.File.WriteAllText("last_invalid_payload.json", jsonBody);
if (isFokFail && sideStr == "SELL")
{
_logger.Warning($"Liquidität für FOK SELL reicht nicht aus. (Orderbook Size limit). Rest-Shares bleiben erhalten.");
return "Nicht genügend Liquidität für vollumfänglichen Verkauf auf diesem Preisniveau (FOK).";
}
else
{
var tickMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"breaks minimum tick size rule: ([\d\.]+)");
if (tickMatch.Success && overrideTickSize == null)
{
if (decimal.TryParse(tickMatch.Groups[1].Value, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal newTickSize))
{
_logger.Info($"🔄 Automatische Anpassung an Markt Tick-Size ({newTickSize}). Order wird erneut berechnet und platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, newTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
}
var decMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"maker amount supports a max accuracy of (\d+) decimals, taker amount a max of (\d+) decimals");
if (decMatch.Success && overrideMakerDecimals == null)
{
if (int.TryParse(decMatch.Groups[1].Value, out int newMaker) && int.TryParse(decMatch.Groups[2].Value, out int newTaker))
{
_logger.Info($"🔄 Automatische Anpassung an Dezimalregeln (Maker: {newMaker}, Taker: {newTaker}). Order wird neu berechnet...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, newMaker, newTaker);
}
}
// Check if error is "invalid fee rate" -> Extract required fee -> Retry!
var match = System.Text.RegularExpressions.Regex.Match(responseContent, @"invalid fee rate \(\d+\), current market's (?:taker|maker) fee: (\d+)");
if (match.Success && actualFeeBps == 0) // Only retry once
{
if (int.TryParse(match.Groups[1].Value, out int newFeeBps))
{
_logger.Info($"🔄 Automatische Anpassung an Fee Rate ({newFeeBps} bps). Order wird erneut platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, newFeeBps, overrideTickSize);
}
}
// Check if error is "Size lower than minimum 5" -> Fallback to MARKET
var sizeMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"Size \([\d\.]+\) lower than the minimum: (\d+)");
if (sizeMatch.Success)
{
if (decimal.TryParse(sizeMatch.Groups[1].Value, out decimal minReq))
{
if (orderType != "MARKET")
{
_logger.Info($"🔄 Automatische Anpassung an Minimum Size Limit (Limitorder < {minReq}). Order wird als MARKET platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, "MARKET", debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
else if (sideStr == "SELL")
{
_logger.Warning($"Verkauf von unter {minReq} Shares auf Polymarket nicht möglich (Orderbook Limit). Position muss aufgestockt werden oder auslaufen.");
return $"Börsenlimit: Mindestens {minReq} Shares erforderlich.";
}
}
}
var balMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"balance: (\d+), sum of active orders: (\d+)");
if (balMatch.Success && sideStr == "SELL")
{
if (decimal.TryParse(balMatch.Groups[1].Value, out decimal totalBal) && decimal.TryParse(balMatch.Groups[2].Value, out decimal activeOrders))
{
decimal availableSharesRaw = totalBal - activeOrders;
decimal availableShares = availableSharesRaw / 1_000_000m;
decimal requiredShares = investAmountUsd / limitPrice;
if (availableShares > 0 && Math.Abs(availableShares - requiredShares) > 0.001m && availableShares < requiredShares)
{
decimal newInvestAmount = availableShares * limitPrice;
_logger.Info($"🔄 Automatische Anpassung an verfügbare Shares (Aktive Orders blockieren {activeOrders / 1000000m} Shares). Verkaufe restliche {availableShares} Shares...");
return await PlaceOrderAsync(account, tokenId, sideStr, newInvestAmount, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
}
}
_logger.Error($"CLOB Order Error ({response.StatusCode}): {responseContent}");
}
return "ERROR";
}
if (response.IsSuccessStatusCode)
{
_logger.Info($"✅ Order Platzierung Erfolgreich! {sideStr} @ {limitPrice:F3}");
return "OK";
}
else
{
_logger.Error($"❌ Order Fehler: {response.StatusCode} - {responseContent}");
return responseContent;
}
}
catch (Exception ex)
{
_logger.Error($"PlaceFokOrderAsync Runtime Fehler: {ex.Message}");
return ex.Message;
}
}
}
}
-799
View File
@@ -1,799 +0,0 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Nethereum.Signer;
using Nethereum.Signer.EIP712;
using Nethereum.ABI.FunctionEncoding.Attributes;
using Nethereum.ABI.EIP712;
using Nethereum.Util;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
[Struct("EIP712Domain")]
public class ClobDomain
{
[Parameter("string", "name", 1)]
public string Name { get; set; } = string.Empty;
[Parameter("string", "version", 2)]
public string Version { get; set; } = "";
[Parameter("uint256", "chainId", 3)]
public System.Numerics.BigInteger ChainId { get; set; }
}
[Struct("EIP712Domain")]
public class CtfDomain
{
[Parameter("string", "name", 1)]
public string Name { get; set; } = string.Empty;
[Parameter("string", "version", 2)]
public string Version { get; set; } = string.Empty;
[Parameter("uint256", "chainId", 3)]
public ulong ChainId { get; set; }
[Parameter("address", "verifyingContract", 4)]
public string VerifyingContract { get; set; } = string.Empty;
}
[Struct("ClobAuth")]
public class ClobAuth
{
[Parameter("address", "address", 1)]
public string Address { get; set; } = string.Empty;
[Parameter("string", "timestamp", 2)]
public string Timestamp { get; set; } = "";
[Parameter("uint256", "nonce", 3)]
public System.Numerics.BigInteger Nonce { get; set; }
[Parameter("string", "message", 4)]
public string Message { get; set; } = string.Empty;
}
[Struct("Order")]
public class CtfOrder
{
[Parameter("uint256", "salt", 1)]
public System.Numerics.BigInteger Salt { get; set; }
[Parameter("address", "maker", 2)]
public string Maker { get; set; } = string.Empty;
[Parameter("address", "signer", 3)]
public string Signer { get; set; } = string.Empty;
[Parameter("address", "taker", 4)]
public string Taker { get; set; } = string.Empty;
[Parameter("uint256", "tokenId", 5)]
public System.Numerics.BigInteger TokenId { get; set; }
[Parameter("uint256", "makerAmount", 6)]
public System.Numerics.BigInteger MakerAmount { get; set; }
[Parameter("uint256", "takerAmount", 7)]
public System.Numerics.BigInteger TakerAmount { get; set; }
[Parameter("uint256", "expiration", 8)]
public System.Numerics.BigInteger Expiration { get; set; }
[Parameter("uint256", "nonce", 9)]
public System.Numerics.BigInteger Nonce { get; set; }
[Parameter("uint256", "feeRateBps", 10)]
public System.Numerics.BigInteger FeeRateBps { get; set; }
[Parameter("uint8", "side", 11)]
public byte Side { get; set; }
[Parameter("uint8", "signatureType", 12)]
public byte SignatureType { get; set; }
}
public class PolymarketClobClient
{
private readonly HttpClient _httpClient;
private readonly TerminalLogger _logger;
private const string ClobHost = "https://clob.polymarket.com";
private const int ChainId = 137;
private static readonly object _fileLock = new object();
public PolymarketClobClient(TerminalLogger logger, HttpClient httpClient)
{
_logger = logger;
_httpClient = httpClient;
}
/// <summary>
/// Creates an HMAC signature for authenticated requests to the Polymarket CLOB.
/// </summary>
private static string GenerateHmacSignature(string secret, string timestamp, string method, string requestPath, string body = "")
{
string payload = timestamp + method + requestPath + body;
// Convert URL-Safe Base64 back to Standard Base64
string b64 = secret.Replace('-', '+').Replace('_', '/');
switch (b64.Length % 4)
{
case 2: b64 += "=="; break;
case 3: b64 += "="; break;
}
byte[] secretBytes = Convert.FromBase64String(b64);
byte[] payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(secretBytes);
byte[] hash = hmac.ComputeHash(payloadBytes);
string signature = Convert.ToBase64String(hash);
return signature.Replace('+', '-').Replace('/', '_');
}
/// <summary>
/// Derives a new Polymarket Level 2 API Key using an EIP712 Message signed by the L1 private key.
/// </summary>
public async Task<(string ApiKey, string ApiSecret, string ApiPassphrase)> DeriveApiKeyAsync(string privateKey, string walletAddress)
{
try
{
var signer = new Eip712TypedDataSigner();
var key = new EthECKey(privateKey);
string computedAddress = key.GetPublicAddress();
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var typedData = new TypedData<ClobDomain>
{
Domain = new ClobDomain
{
Name = "ClobAuthDomain",
Version = "1",
ChainId = new System.Numerics.BigInteger(ChainId)
},
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(ClobDomain), typeof(ClobAuth)),
PrimaryType = "ClobAuth"
};
var clobAuth = new ClobAuth
{
Address = computedAddress,
Timestamp = timestamp,
Nonce = new System.Numerics.BigInteger(0),
Message = "This message attests that I control the given wallet"
};
var encoder = new Nethereum.ABI.EIP712.Eip712TypedDataEncoder();
var rawData = encoder.EncodeTypedData(clobAuth, typedData);
_logger.Warning($"DEBUG_CS_RAW_DATA: {Nethereum.Hex.HexConvertors.Extensions.HexByteConvertorExtensions.ToHex(rawData)}");
string signature = signer.SignTypedDataV4(clobAuth, typedData, key);
_logger.Warning($"DEBUG_CS_SIG: {signature}");
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}/auth/derive-api-key");
request.Headers.Add("POLY_ADDRESS", computedAddress);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_NONCE", "0");
using (var response = await _httpClient.SendAsync(request))
{
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
string apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? "";
string secret = doc.RootElement.GetProperty("secret").GetString() ?? "";
string passphrase = doc.RootElement.GetProperty("passphrase").GetString() ?? "";
return (apiKey, secret, passphrase);
}
}
_logger.Warning($"Derivation failed. Attempting to CREATE new Api Key L2 instead...");
using (var request2 = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}/auth/api-key"))
{
request2.Headers.Add("POLY_ADDRESS", computedAddress);
request2.Headers.Add("POLY_SIGNATURE", signature);
request2.Headers.Add("POLY_TIMESTAMP", timestamp);
request2.Headers.Add("POLY_NONCE", "0");
using (var response2 = await _httpClient.SendAsync(request2))
{
if (response2.IsSuccessStatusCode)
{
var jsonStr = await response2.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
string apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? "";
string secret = doc.RootElement.GetProperty("secret").GetString() ?? "";
string passphrase = doc.RootElement.GetProperty("passphrase").GetString() ?? "";
return (apiKey, secret, passphrase);
}
else
{
string err = await response2.Content.ReadAsStringAsync();
_logger.Error($"Failed to execute L1 Auth: {response2.StatusCode} {err}");
}
}
}
}
catch (Exception ex)
{
_logger.Error($"DeriveApiKeyAsync Exception: {ex.Message}");
}
return (string.Empty, string.Empty, string.Empty);
}
public async Task<decimal> GetUsdcBalanceAsync(AccountState acc, bool isRetry = false)
{
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
{
_logger.Warning($"🔑 [{acc.Name}] Skipping balance fetch: ApiKey={!string.IsNullOrEmpty(acc.ApiKey)}, Secret={!string.IsNullOrEmpty(acc.ApiSecret)}, Pass={!string.IsNullOrEmpty(acc.ApiPassphrase)}, PK={!string.IsNullOrEmpty(acc.PrivateKey)}");
return 0;
}
try
{
string endpoint = "/balance-allowance";
string requestUrl = $"{endpoint}?asset_type=COLLATERAL&signature_type=2";
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
// Python SDK signs ONLY the base path, not the query params
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "GET", endpoint);
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}{requestUrl}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
using var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
_logger.Info($"💰 [{acc.Name}] Balance API Response: {jsonStr}");
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("balance", out var balProp))
{
var balanceStr = balProp.GetString();
if (decimal.TryParse(balanceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal balRaw))
{
decimal finalBal = balRaw / 1_000_000m;
_logger.Info($"💰 [{acc.Name}] Parsed Balance: {finalBal} USDC (raw: {balRaw})");
return finalBal;
}
}
_logger.Warning($"💰 [{acc.Name}] Could not parse 'balance' from response: {jsonStr}");
}
else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"🌐 [{acc.Name}] API Keys expired/invalid. Deriving new L2 Keys from PrivateKey...");
if (!isRetry && !string.IsNullOrEmpty(acc.PrivateKey) && !string.IsNullOrEmpty(acc.WalletAddress))
{
var fallbackKeyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
var newKeys = await DeriveApiKeyAsync(acc.PrivateKey, fallbackKeyObj.GetPublicAddress());
if (!string.IsNullOrEmpty(newKeys.ApiKey))
{
acc.ApiKey = newKeys.ApiKey;
acc.ApiSecret = newKeys.ApiSecret;
acc.ApiPassphrase = newKeys.ApiPassphrase;
_logger.Info($"🌐 [{acc.Name}] Successfully derived new L2 Keys! Resuming in 2.5s...");
// Await propagation of new keys inside Polymarket's Gamma backend
await Task.Delay(2500);
// Retry recursively strictly once
return await GetUsdcBalanceAsync(acc, true);
}
}
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
}
}
catch (Exception ex)
{
_logger.Error($"CLOB Balance Fetch Error: {ex.Message}");
}
return 0;
}
public async Task<System.Collections.Generic.List<(string Id, string Side, decimal Price)>> GetOpenOrdersAsync(AccountState acc, string assetId)
{
var result = new System.Collections.Generic.List<(string Id, string Side, decimal Price)>();
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
return result;
try
{
string endpoint = "/orders";
string requestUrl = $"{endpoint}?asset_id={assetId}";
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "GET", endpoint);
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}{requestUrl}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
using var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.TryGetProperty("data", out var dataArr) && dataArr.ValueKind == JsonValueKind.Array)
{
foreach (var orderLine in dataArr.EnumerateArray())
{
if (orderLine.TryGetProperty("orderID", out var oid) || orderLine.TryGetProperty("id", out oid))
{
string idStr = oid.GetString() ?? "";
string sideStr = orderLine.TryGetProperty("side", out var s) ? (s.GetString() ?? "") : "";
string priceStr = orderLine.TryGetProperty("price", out var p) ? (p.GetString() ?? "0") : "0";
decimal.TryParse(priceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal priceDec);
if (!string.IsNullOrEmpty(idStr))
result.Add((idStr, sideStr, priceDec));
}
}
}
else if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var orderLine in doc.RootElement.EnumerateArray())
{
if (orderLine.TryGetProperty("orderID", out var oid) || orderLine.TryGetProperty("id", out oid))
{
string idStr = oid.GetString() ?? "";
string sideStr = orderLine.TryGetProperty("side", out var s) ? (s.GetString() ?? "") : "";
string priceStr = orderLine.TryGetProperty("price", out var p) ? (p.GetString() ?? "0") : "0";
decimal.TryParse(priceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal priceDec);
if (!string.IsNullOrEmpty(idStr))
result.Add((idStr, sideStr, priceDec));
}
}
}
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"Failed to GET open orders for {assetId}: {response.StatusCode} {errStr}");
}
}
catch (Exception ex)
{
_logger.Error($"GetOpenOrdersAsync Error: {ex.Message}");
}
return result;
}
public async Task<bool> CancelOrderAsync(AccountState acc, string orderId)
{
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
return false;
try
{
string endpoint = "/order";
var reqBody = new { orderID = orderId };
string jsonBody = JsonSerializer.Serialize(reqBody);
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "DELETE", endpoint, jsonBody);
using var request = new HttpRequestMessage(HttpMethod.Delete, $"{ClobHost}{endpoint}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
using var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
_logger.Info($"🚮 [{acc.Name}] Stornierung erfolgreich. OrderID: {orderId}");
return true;
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"Failed to cancel order {orderId}: {response.StatusCode} {errStr}");
return false;
}
}
catch (Exception ex)
{
_logger.Error($"CancelOrderAsync Error: {ex.Message}");
return false;
}
}
public async Task CancelConflictingOrdersAsync(AccountState acc, string assetId, decimal newPrice, string sideStr)
{
var openOrders = await GetOpenOrdersAsync(acc, assetId);
if (openOrders.Count > 0)
{
var tasks = new System.Collections.Generic.List<Task>();
foreach (var order in openOrders)
{
bool shouldCancel = false;
if (sideStr.Equals("SELL", StringComparison.OrdinalIgnoreCase))
{
shouldCancel = true;
_logger.Info($"⚠️ [{acc.Name}] Storniere Order {order.Id} wegen Verkaufs-Signal des Master-Traders.");
}
else if (sideStr.Equals("BUY", StringComparison.OrdinalIgnoreCase) && order.Side.Equals("BUY", StringComparison.OrdinalIgnoreCase))
{
if (Math.Abs(order.Price - newPrice) > 0.001m)
{
shouldCancel = true;
_logger.Info($"⚠️ [{acc.Name}] Storniere veraltete Order {order.Id} (Alter Preis: {order.Price:F3}, Neuer Preis: {newPrice:F3})");
}
else
{
_logger.Info($"✅ [{acc.Name}] Behalte bestehende Order {order.Id} (Preis identisch: {order.Price:F3})");
}
}
if (shouldCancel)
{
tasks.Add(CancelOrderAsync(acc, order.Id));
}
}
if (tasks.Count > 0)
{
await Task.WhenAll(tasks);
// Minimal delay to ensure rapid executions don't conflict with in-flight deletions
await Task.Delay(150);
}
}
}
private static System.Numerics.BigInteger GenerateSalt()
{
// Generate a salt similar to Py Clob Client (fits safely in a standard 64-bit int / JS Number)
long t = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
int r = System.Security.Cryptography.RandomNumberGenerator.GetInt32(0, 10000);
return new System.Numerics.BigInteger(t * 10000 + r);
}
public static (decimal shares, decimal usdc, decimal makerRaw, decimal takerRaw) CalculateExactOrderAmounts(decimal investAmountUsd, decimal rawPrice, decimal limitPrice, string sideStr, string orderType = "FOK", decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
decimal tickSize = overrideTickSize ?? 0.001m;
int priceDec, sizeDec, amtDec;
if (tickSize >= 0.1m) { priceDec = 1; sizeDec = 2; amtDec = 3; }
else if (tickSize >= 0.01m) { priceDec = 2; sizeDec = 2; amtDec = 4; }
else if (tickSize >= 0.001m) { priceDec = 3; sizeDec = 2; amtDec = 5; }
else { priceDec = 4; sizeDec = 2; amtDec = 6; }
decimal priceRounded = Math.Round(limitPrice > 0 ? limitPrice : rawPrice, priceDec, MidpointRounding.AwayFromZero);
if (priceRounded < tickSize) priceRounded = tickSize;
decimal executedShares = 0m;
decimal executedUsdc = 0m;
decimal finalMakerAmountRaw = 0m;
decimal finalTakerAmountRaw = 0m;
if (sideStr.ToUpper() == "BUY")
{
decimal rawTakerShares = investAmountUsd / priceRounded;
decimal multiplier = (decimal)Math.Pow(10, sizeDec);
decimal takerShares = Math.Floor(rawTakerShares * multiplier) / multiplier;
if (takerShares <= 0) return (-1, -1, 0, 0);
decimal makerUsd = 0m;
// Polymarket strictly enforces $1.00 minimum for MARKET BUYS and verifies it against the supported shares.
// We increment takerShares until the floored USDC amount supports the exact shares without dropping below $1.00.
decimal step = 1.0m / multiplier;
while (takerShares > 0)
{
makerUsd = takerShares * priceRounded;
int actDec = BitConverter.GetBytes(decimal.GetBits(makerUsd)[3])[2];
if (actDec > amtDec)
{
decimal mul2 = (decimal)Math.Pow(10, amtDec + 4);
makerUsd = Math.Ceiling(makerUsd * mul2) / mul2;
if (BitConverter.GetBytes(decimal.GetBits(makerUsd)[3])[2] > amtDec)
{
decimal mul3 = (decimal)Math.Pow(10, amtDec);
makerUsd = Math.Floor(makerUsd * mul3) / mul3;
}
}
decimal supportedShares = Math.Floor((makerUsd / priceRounded) * multiplier) / multiplier;
if (makerUsd >= 1.0m && supportedShares >= takerShares)
break;
takerShares += step;
}
finalTakerAmountRaw = Math.Round(takerShares * 1_000_000m);
finalMakerAmountRaw = Math.Round(makerUsd * 1_000_000m);
executedShares = takerShares;
executedUsdc = makerUsd;
}
else
{
decimal sharesRaw = investAmountUsd / priceRounded;
decimal multiplier = (decimal)Math.Pow(10, sizeDec);
decimal makerShares = Math.Floor(sharesRaw * multiplier) / multiplier;
// Polymarket STRICTLY enforces a 5 share minimum for ANY sell order on the CLOB
if (makerShares < 5.0m) return (-1, -1, 0, 0);
decimal takerUsd = makerShares * priceRounded;
int actDec = BitConverter.GetBytes(decimal.GetBits(takerUsd)[3])[2];
if (actDec > amtDec)
{
decimal mul2 = (decimal)Math.Pow(10, amtDec + 4);
takerUsd = Math.Ceiling(takerUsd * mul2) / mul2;
if (BitConverter.GetBytes(decimal.GetBits(takerUsd)[3])[2] > amtDec)
{
decimal mul3 = (decimal)Math.Pow(10, amtDec);
takerUsd = Math.Floor(takerUsd * mul3) / mul3;
}
}
finalMakerAmountRaw = Math.Round(makerShares * 1_000_000m);
finalTakerAmountRaw = Math.Round(takerUsd * 1_000_000m);
executedShares = makerShares;
executedUsdc = takerUsd;
}
return (executedShares, executedUsdc, finalMakerAmountRaw, finalTakerAmountRaw);
}
/// <summary>
/// Executes a native EIP-712 signed order (default Fill-Or-Kill)
/// </summary>
public async Task<string> PlaceOrderAsync(AccountState account, string tokenId, string sideStr, decimal investAmountUsd, decimal limitPrice, string orderType = "FOK", bool debugPayloadLog = false, bool isNegRisk = false, int actualFeeBps = 0, decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
if (string.IsNullOrEmpty(account.PrivateKey) || string.IsNullOrEmpty(account.ApiKey))
return "Error: Missing API or Private Keys";
try
{
var signer = new Eip712TypedDataSigner();
var key = new EthECKey(account.PrivateKey);
var typedData = new TypedData<CtfDomain>
{
Domain = new CtfDomain
{
Name = "Polymarket CTF Exchange",
Version = "1",
ChainId = ChainId,
VerifyingContract = isNegRisk ? "0xC5d563A36AE78145C45a50134d48A1215220f80a" : "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
},
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(CtfDomain), typeof(CtfOrder)),
PrimaryType = "Order"
};
var amounts = CalculateExactOrderAmounts(investAmountUsd, limitPrice, limitPrice, sideStr, orderType, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
if (amounts.shares <= 0)
return $"Mathematical tick size error: Balance too small to meet fractional quantum limit for exact price matching";
decimal makerAmountRaw = amounts.makerRaw;
decimal takerAmountRaw = amounts.takerRaw;
System.Numerics.BigInteger parsedTokenId;
if (tokenId.StartsWith("0x") || tokenId.Any(c => "abcdefABCDEF".Contains(c)))
{
parsedTokenId = new Nethereum.Hex.HexTypes.HexBigInteger(tokenId.StartsWith("0x") ? tokenId : "0x" + tokenId).Value;
}
else
{
parsedTokenId = System.Numerics.BigInteger.Parse(tokenId);
}
var ctfOrder = new CtfOrder
{
Salt = GenerateSalt(),
Maker = account.WalletAddress,
Signer = key.GetPublicAddress(),
Taker = "0x0000000000000000000000000000000000000000",
TokenId = parsedTokenId,
MakerAmount = new System.Numerics.BigInteger(makerAmountRaw),
TakerAmount = new System.Numerics.BigInteger(takerAmountRaw),
Expiration = 0,
Nonce = 0,
FeeRateBps = new System.Numerics.BigInteger(actualFeeBps),
Side = sideStr.ToUpper() == "BUY" ? (byte)0 : (byte)1,
SignatureType = 2
};
string signature = signer.SignTypedDataV4(ctfOrder, typedData, key);
var reqBody = new
{
order = new
{
salt = (long)ctfOrder.Salt,
maker = ctfOrder.Maker.ToLower(),
signer = ctfOrder.Signer.ToLower(),
taker = ctfOrder.Taker.ToLower(),
tokenId = ctfOrder.TokenId.ToString(),
makerAmount = ctfOrder.MakerAmount.ToString(),
takerAmount = ctfOrder.TakerAmount.ToString(),
expiration = ctfOrder.Expiration.ToString(),
nonce = ctfOrder.Nonce.ToString(),
feeRateBps = ctfOrder.FeeRateBps.ToString(),
side = ctfOrder.Side == 0 ? "BUY" : "SELL",
signatureType = ctfOrder.SignatureType,
signature = signature
},
owner = account.ApiKey,
orderType = orderType
};
string jsonBody = JsonSerializer.Serialize(reqBody);
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string requestPath = "/order";
string hmacSig = GenerateHmacSignature(account.ApiSecret, timestamp, "POST", requestPath, jsonBody);
using var request = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}{requestPath}");
var keyObj = new EthECKey(account.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", account.ApiKey);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_SIGNATURE", hmacSig);
request.Headers.Add("POLY_PASSPHRASE", account.ApiPassphrase);
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
if (debugPayloadLog)
{
_logger.Debug($"[CLOB-PAYLOAD] -> {jsonBody}");
}
using var response = await _httpClient.SendAsync(request);
var responseContent = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
bool isFokFail = responseContent.Contains("FOK orders are fully filled or killed");
if (isFokFail && sideStr == "BUY")
{
// Dampen FOK failed BUY logs. Usually means target price/liquidity not met for full copy size.
// We skip it silently.
return "SKIPPED_LIQUIDITY";
}
lock (_fileLock)
{
System.IO.File.WriteAllText("last_invalid_payload.json", jsonBody);
}
if (isFokFail && sideStr == "SELL")
{
_logger.Warning($"Liquidität für FOK SELL reicht nicht aus. (Orderbook Size limit). Rest-Shares bleiben erhalten.");
return "Nicht genügend Liquidität für vollumfänglichen Verkauf auf diesem Preisniveau (FOK).";
}
else
{
var tickMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"breaks minimum tick size rule: ([\d\.]+)");
if (tickMatch.Success && overrideTickSize == null)
{
if (decimal.TryParse(tickMatch.Groups[1].Value, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal newTickSize))
{
_logger.Info($"🔄 Automatische Anpassung an Markt Tick-Size ({newTickSize}). Order wird erneut berechnet und platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, newTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
}
var decMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"maker amount supports a max accuracy of (\d+) decimals, taker amount a max of (\d+) decimals");
if (decMatch.Success && overrideMakerDecimals == null)
{
if (int.TryParse(decMatch.Groups[1].Value, out int newMaker) && int.TryParse(decMatch.Groups[2].Value, out int newTaker))
{
_logger.Info($"🔄 Automatische Anpassung an Dezimalregeln (Maker: {newMaker}, Taker: {newTaker}). Order wird neu berechnet...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, newMaker, newTaker);
}
}
// Check if error is "invalid fee rate" -> Extract required fee -> Retry!
var match = System.Text.RegularExpressions.Regex.Match(responseContent, @"invalid fee rate \(\d+\), current market's (?:taker|maker) fee: (\d+)");
if (match.Success && actualFeeBps == 0) // Only retry once
{
if (int.TryParse(match.Groups[1].Value, out int newFeeBps))
{
_logger.Info($"🔄 Automatische Anpassung an Fee Rate ({newFeeBps} bps). Order wird erneut platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, newFeeBps, overrideTickSize);
}
}
// Check if error is "Size lower than minimum 5" -> Fallback to MARKET
var sizeMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"Size \([\d\.]+\) lower than the minimum: (\d+)");
if (sizeMatch.Success)
{
if (decimal.TryParse(sizeMatch.Groups[1].Value, out decimal minReq))
{
if (orderType != "MARKET")
{
_logger.Info($"🔄 Automatische Anpassung an Minimum Size Limit (Limitorder < {minReq}). Order wird als MARKET platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, "MARKET", debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
else if (sideStr == "SELL")
{
_logger.Warning($"Verkauf von unter {minReq} Shares auf Polymarket nicht möglich (Orderbook Limit). Position muss aufgestockt werden oder auslaufen.");
return $"Börsenlimit: Mindestens {minReq} Shares erforderlich.";
}
}
}
var balMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"balance: (\d+), sum of active orders: (\d+)");
if (balMatch.Success && sideStr == "SELL")
{
if (decimal.TryParse(balMatch.Groups[1].Value, out decimal totalBal) && decimal.TryParse(balMatch.Groups[2].Value, out decimal activeOrders))
{
decimal availableSharesRaw = totalBal - activeOrders;
decimal availableShares = availableSharesRaw / 1_000_000m;
decimal requiredShares = investAmountUsd / limitPrice;
if (availableShares > 0 && Math.Abs(availableShares - requiredShares) > 0.001m && availableShares < requiredShares)
{
decimal newInvestAmount = availableShares * limitPrice;
_logger.Info($"🔄 Automatische Anpassung an verfügbare Shares (Aktive Orders blockieren {activeOrders / 1000000m} Shares). Verkaufe restliche {availableShares} Shares...");
return await PlaceOrderAsync(account, tokenId, sideStr, newInvestAmount, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
}
}
_logger.Error($"CLOB Order Error ({response.StatusCode}): {responseContent}");
}
return "ERROR";
}
if (response.IsSuccessStatusCode)
{
_logger.Info($"✅ Order Platzierung Erfolgreich! {sideStr} @ {limitPrice:F3}");
return "OK";
}
else
{
_logger.Error($"❌ Order Fehler: {response.StatusCode} - {responseContent}");
return responseContent;
}
}
catch (Exception ex)
{
_logger.Error($"PlaceFokOrderAsync Runtime Fehler: {ex.Message}");
return ex.Message;
}
}
}
}
-799
View File
@@ -1,799 +0,0 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Nethereum.Signer;
using Nethereum.Signer.EIP712;
using Nethereum.ABI.FunctionEncoding.Attributes;
using Nethereum.ABI.EIP712;
using Nethereum.Util;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
[Struct("EIP712Domain")]
public class ClobDomain
{
[Parameter("string", "name", 1)]
public string Name { get; set; } = string.Empty;
[Parameter("string", "version", 2)]
public string Version { get; set; } = "";
[Parameter("uint256", "chainId", 3)]
public System.Numerics.BigInteger ChainId { get; set; }
}
[Struct("EIP712Domain")]
public class CtfDomain
{
[Parameter("string", "name", 1)]
public string Name { get; set; } = string.Empty;
[Parameter("string", "version", 2)]
public string Version { get; set; } = string.Empty;
[Parameter("uint256", "chainId", 3)]
public ulong ChainId { get; set; }
[Parameter("address", "verifyingContract", 4)]
public string VerifyingContract { get; set; } = string.Empty;
}
[Struct("ClobAuth")]
public class ClobAuth
{
[Parameter("address", "address", 1)]
public string Address { get; set; } = string.Empty;
[Parameter("string", "timestamp", 2)]
public string Timestamp { get; set; } = "";
[Parameter("uint256", "nonce", 3)]
public System.Numerics.BigInteger Nonce { get; set; }
[Parameter("string", "message", 4)]
public string Message { get; set; } = string.Empty;
}
[Struct("Order")]
public class CtfOrder
{
[Parameter("uint256", "salt", 1)]
public System.Numerics.BigInteger Salt { get; set; }
[Parameter("address", "maker", 2)]
public string Maker { get; set; } = string.Empty;
[Parameter("address", "signer", 3)]
public string Signer { get; set; } = string.Empty;
[Parameter("address", "taker", 4)]
public string Taker { get; set; } = string.Empty;
[Parameter("uint256", "tokenId", 5)]
public System.Numerics.BigInteger TokenId { get; set; }
[Parameter("uint256", "makerAmount", 6)]
public System.Numerics.BigInteger MakerAmount { get; set; }
[Parameter("uint256", "takerAmount", 7)]
public System.Numerics.BigInteger TakerAmount { get; set; }
[Parameter("uint256", "expiration", 8)]
public System.Numerics.BigInteger Expiration { get; set; }
[Parameter("uint256", "nonce", 9)]
public System.Numerics.BigInteger Nonce { get; set; }
[Parameter("uint256", "feeRateBps", 10)]
public System.Numerics.BigInteger FeeRateBps { get; set; }
[Parameter("uint8", "side", 11)]
public byte Side { get; set; }
[Parameter("uint8", "signatureType", 12)]
public byte SignatureType { get; set; }
}
public class PolymarketClobClient
{
private readonly HttpClient _httpClient;
private readonly TerminalLogger _logger;
private const string ClobHost = "https://clob.polymarket.com";
private const int ChainId = 137;
private static readonly object _fileLock = new object();
public PolymarketClobClient(TerminalLogger logger, HttpClient httpClient)
{
_logger = logger;
_httpClient = httpClient;
}
/// <summary>
/// Creates an HMAC signature for authenticated requests to the Polymarket CLOB.
/// </summary>
private static string GenerateHmacSignature(string secret, string timestamp, string method, string requestPath, string body = "")
{
string payload = timestamp + method + requestPath + body;
// Convert URL-Safe Base64 back to Standard Base64
string b64 = secret.Replace('-', '+').Replace('_', '/');
switch (b64.Length % 4)
{
case 2: b64 += "=="; break;
case 3: b64 += "="; break;
}
byte[] secretBytes = Convert.FromBase64String(b64);
byte[] payloadBytes = Encoding.UTF8.GetBytes(payload);
using var hmac = new HMACSHA256(secretBytes);
byte[] hash = hmac.ComputeHash(payloadBytes);
string signature = Convert.ToBase64String(hash);
return signature.Replace('+', '-').Replace('/', '_');
}
/// <summary>
/// Derives a new Polymarket Level 2 API Key using an EIP712 Message signed by the L1 private key.
/// </summary>
public async Task<(string ApiKey, string ApiSecret, string ApiPassphrase)> DeriveApiKeyAsync(string privateKey, string walletAddress)
{
try
{
var signer = new Eip712TypedDataSigner();
var key = new EthECKey(privateKey);
string computedAddress = key.GetPublicAddress();
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var typedData = new TypedData<ClobDomain>
{
Domain = new ClobDomain
{
Name = "ClobAuthDomain",
Version = "1",
ChainId = new System.Numerics.BigInteger(ChainId)
},
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(ClobDomain), typeof(ClobAuth)),
PrimaryType = "ClobAuth"
};
var clobAuth = new ClobAuth
{
Address = computedAddress,
Timestamp = timestamp,
Nonce = new System.Numerics.BigInteger(0),
Message = "This message attests that I control the given wallet"
};
var encoder = new Nethereum.ABI.EIP712.Eip712TypedDataEncoder();
var rawData = encoder.EncodeTypedData(clobAuth, typedData);
_logger.Warning($"DEBUG_CS_RAW_DATA: {Nethereum.Hex.HexConvertors.Extensions.HexByteConvertorExtensions.ToHex(rawData)}");
string signature = signer.SignTypedDataV4(clobAuth, typedData, key);
_logger.Warning($"DEBUG_CS_SIG: {signature}");
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}/auth/derive-api-key");
request.Headers.Add("POLY_ADDRESS", computedAddress);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_NONCE", "0");
using (var response = await _httpClient.SendAsync(request))
{
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
string apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? "";
string secret = doc.RootElement.GetProperty("secret").GetString() ?? "";
string passphrase = doc.RootElement.GetProperty("passphrase").GetString() ?? "";
return (apiKey, secret, passphrase);
}
}
_logger.Warning($"Derivation failed. Attempting to CREATE new Api Key L2 instead...");
using (var request2 = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}/auth/api-key"))
{
request2.Headers.Add("POLY_ADDRESS", computedAddress);
request2.Headers.Add("POLY_SIGNATURE", signature);
request2.Headers.Add("POLY_TIMESTAMP", timestamp);
request2.Headers.Add("POLY_NONCE", "0");
using (var response2 = await _httpClient.SendAsync(request2))
{
if (response2.IsSuccessStatusCode)
{
var jsonStr = await response2.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
string apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? "";
string secret = doc.RootElement.GetProperty("secret").GetString() ?? "";
string passphrase = doc.RootElement.GetProperty("passphrase").GetString() ?? "";
return (apiKey, secret, passphrase);
}
else
{
string err = await response2.Content.ReadAsStringAsync();
_logger.Error($"Failed to execute L1 Auth: {response2.StatusCode} {err}");
}
}
}
}
catch (Exception ex)
{
_logger.Error($"DeriveApiKeyAsync Exception: {ex.Message}");
}
return (string.Empty, string.Empty, string.Empty);
}
public async Task<decimal> GetUsdcBalanceAsync(AccountState acc, bool isRetry = false)
{
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
{
_logger.Warning($"🔑 [{acc.Name}] Skipping balance fetch: ApiKey={!string.IsNullOrEmpty(acc.ApiKey)}, Secret={!string.IsNullOrEmpty(acc.ApiSecret)}, Pass={!string.IsNullOrEmpty(acc.ApiPassphrase)}, PK={!string.IsNullOrEmpty(acc.PrivateKey)}");
return 0;
}
try
{
string endpoint = "/balance-allowance";
string requestUrl = $"{endpoint}?asset_type=COLLATERAL&signature_type=2";
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
// Python SDK signs ONLY the base path, not the query params
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "GET", endpoint);
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}{requestUrl}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
using var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
_logger.Info($"💰 [{acc.Name}] Balance API Response: {jsonStr}");
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("balance", out var balProp))
{
var balanceStr = balProp.GetString();
if (decimal.TryParse(balanceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal balRaw))
{
decimal finalBal = balRaw / 1_000_000m;
_logger.Info($"💰 [{acc.Name}] Parsed Balance: {finalBal} USDC (raw: {balRaw})");
return finalBal;
}
}
_logger.Warning($"💰 [{acc.Name}] Could not parse 'balance' from response: {jsonStr}");
}
else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"🌐 [{acc.Name}] API Keys expired/invalid. Deriving new L2 Keys from PrivateKey...");
if (!isRetry && !string.IsNullOrEmpty(acc.PrivateKey) && !string.IsNullOrEmpty(acc.WalletAddress))
{
var fallbackKeyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
var newKeys = await DeriveApiKeyAsync(acc.PrivateKey, fallbackKeyObj.GetPublicAddress());
if (!string.IsNullOrEmpty(newKeys.ApiKey))
{
acc.ApiKey = newKeys.ApiKey;
acc.ApiSecret = newKeys.ApiSecret;
acc.ApiPassphrase = newKeys.ApiPassphrase;
_logger.Info($"🌐 [{acc.Name}] Successfully derived new L2 Keys! Resuming in 2.5s...");
// Await propagation of new keys inside Polymarket's Gamma backend
await Task.Delay(2500);
// Retry recursively strictly once
return await GetUsdcBalanceAsync(acc, true);
}
}
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Error($"CLOB Balance Fetch failed: {response.StatusCode} {errStr}");
}
}
catch (Exception ex)
{
_logger.Error($"CLOB Balance Fetch Error: {ex.Message}");
}
return 0;
}
public async Task<System.Collections.Generic.List<(string Id, string Side, decimal Price)>> GetOpenOrdersAsync(AccountState acc, string assetId)
{
var result = new System.Collections.Generic.List<(string Id, string Side, decimal Price)>();
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
return result;
try
{
string endpoint = "/data/orders";
string requestUrl = $"{endpoint}?asset_id={assetId}";
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "GET", endpoint);
var request = new HttpRequestMessage(HttpMethod.Get, $"{ClobHost}{requestUrl}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
using var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
var jsonStr = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(jsonStr);
if (doc.RootElement.TryGetProperty("data", out var dataArr) && dataArr.ValueKind == JsonValueKind.Array)
{
foreach (var orderLine in dataArr.EnumerateArray())
{
if (orderLine.TryGetProperty("orderID", out var oid) || orderLine.TryGetProperty("id", out oid))
{
string idStr = oid.GetString() ?? "";
string sideStr = orderLine.TryGetProperty("side", out var s) ? (s.GetString() ?? "") : "";
string priceStr = orderLine.TryGetProperty("price", out var p) ? (p.GetString() ?? "0") : "0";
decimal.TryParse(priceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal priceDec);
if (!string.IsNullOrEmpty(idStr))
result.Add((idStr, sideStr, priceDec));
}
}
}
else if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var orderLine in doc.RootElement.EnumerateArray())
{
if (orderLine.TryGetProperty("orderID", out var oid) || orderLine.TryGetProperty("id", out oid))
{
string idStr = oid.GetString() ?? "";
string sideStr = orderLine.TryGetProperty("side", out var s) ? (s.GetString() ?? "") : "";
string priceStr = orderLine.TryGetProperty("price", out var p) ? (p.GetString() ?? "0") : "0";
decimal.TryParse(priceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal priceDec);
if (!string.IsNullOrEmpty(idStr))
result.Add((idStr, sideStr, priceDec));
}
}
}
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"Failed to GET open orders for {assetId}: {response.StatusCode} {errStr}");
}
}
catch (Exception ex)
{
_logger.Error($"GetOpenOrdersAsync Error: {ex.Message}");
}
return result;
}
public async Task<bool> CancelOrderAsync(AccountState acc, string orderId)
{
if (string.IsNullOrEmpty(acc.ApiKey) || string.IsNullOrEmpty(acc.ApiSecret) || string.IsNullOrEmpty(acc.ApiPassphrase) || string.IsNullOrEmpty(acc.PrivateKey))
return false;
try
{
string endpoint = "/order";
var reqBody = new { orderID = orderId };
string jsonBody = JsonSerializer.Serialize(reqBody);
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string signature = GenerateHmacSignature(acc.ApiSecret, timestamp, "DELETE", endpoint, jsonBody);
using var request = new HttpRequestMessage(HttpMethod.Delete, $"{ClobHost}{endpoint}");
var keyObj = new EthECKey(acc.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", acc.ApiKey);
request.Headers.Add("POLY_SIGNATURE", signature);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_PASSPHRASE", acc.ApiPassphrase);
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
using var response = await _httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
_logger.Info($"🚮 [{acc.Name}] Stornierung erfolgreich. OrderID: {orderId}");
return true;
}
else
{
string errStr = await response.Content.ReadAsStringAsync();
_logger.Warning($"Failed to cancel order {orderId}: {response.StatusCode} {errStr}");
return false;
}
}
catch (Exception ex)
{
_logger.Error($"CancelOrderAsync Error: {ex.Message}");
return false;
}
}
public async Task CancelConflictingOrdersAsync(AccountState acc, string assetId, decimal newPrice, string sideStr)
{
var openOrders = await GetOpenOrdersAsync(acc, assetId);
if (openOrders.Count > 0)
{
var tasks = new System.Collections.Generic.List<Task>();
foreach (var order in openOrders)
{
bool shouldCancel = false;
if (sideStr.Equals("SELL", StringComparison.OrdinalIgnoreCase))
{
shouldCancel = true;
_logger.Info($"⚠️ [{acc.Name}] Storniere Order {order.Id} wegen Verkaufs-Signal des Master-Traders.");
}
else if (sideStr.Equals("BUY", StringComparison.OrdinalIgnoreCase) && order.Side.Equals("BUY", StringComparison.OrdinalIgnoreCase))
{
if (Math.Abs(order.Price - newPrice) > 0.001m)
{
shouldCancel = true;
_logger.Info($"⚠️ [{acc.Name}] Storniere veraltete Order {order.Id} (Alter Preis: {order.Price:F3}, Neuer Preis: {newPrice:F3})");
}
else
{
_logger.Info($"✅ [{acc.Name}] Behalte bestehende Order {order.Id} (Preis identisch: {order.Price:F3})");
}
}
if (shouldCancel)
{
tasks.Add(CancelOrderAsync(acc, order.Id));
}
}
if (tasks.Count > 0)
{
await Task.WhenAll(tasks);
// Minimal delay to ensure rapid executions don't conflict with in-flight deletions
await Task.Delay(150);
}
}
}
private static System.Numerics.BigInteger GenerateSalt()
{
// Generate a salt similar to Py Clob Client (fits safely in a standard 64-bit int / JS Number)
long t = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
int r = System.Security.Cryptography.RandomNumberGenerator.GetInt32(0, 10000);
return new System.Numerics.BigInteger(t * 10000 + r);
}
public static (decimal shares, decimal usdc, decimal makerRaw, decimal takerRaw) CalculateExactOrderAmounts(decimal investAmountUsd, decimal rawPrice, decimal limitPrice, string sideStr, string orderType = "FOK", decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
decimal tickSize = overrideTickSize ?? 0.001m;
int priceDec, sizeDec, amtDec;
if (tickSize >= 0.1m) { priceDec = 1; sizeDec = 2; amtDec = 3; }
else if (tickSize >= 0.01m) { priceDec = 2; sizeDec = 2; amtDec = 4; }
else if (tickSize >= 0.001m) { priceDec = 3; sizeDec = 2; amtDec = 5; }
else { priceDec = 4; sizeDec = 2; amtDec = 6; }
decimal priceRounded = Math.Round(limitPrice > 0 ? limitPrice : rawPrice, priceDec, MidpointRounding.AwayFromZero);
if (priceRounded < tickSize) priceRounded = tickSize;
decimal executedShares = 0m;
decimal executedUsdc = 0m;
decimal finalMakerAmountRaw = 0m;
decimal finalTakerAmountRaw = 0m;
if (sideStr.ToUpper() == "BUY")
{
decimal rawTakerShares = investAmountUsd / priceRounded;
decimal multiplier = (decimal)Math.Pow(10, sizeDec);
decimal takerShares = Math.Floor(rawTakerShares * multiplier) / multiplier;
if (takerShares <= 0) return (-1, -1, 0, 0);
decimal makerUsd = 0m;
// Polymarket strictly enforces $1.00 minimum for MARKET BUYS and verifies it against the supported shares.
// We increment takerShares until the floored USDC amount supports the exact shares without dropping below $1.00.
decimal step = 1.0m / multiplier;
while (takerShares > 0)
{
makerUsd = takerShares * priceRounded;
int actDec = BitConverter.GetBytes(decimal.GetBits(makerUsd)[3])[2];
if (actDec > amtDec)
{
decimal mul2 = (decimal)Math.Pow(10, amtDec + 4);
makerUsd = Math.Ceiling(makerUsd * mul2) / mul2;
if (BitConverter.GetBytes(decimal.GetBits(makerUsd)[3])[2] > amtDec)
{
decimal mul3 = (decimal)Math.Pow(10, amtDec);
makerUsd = Math.Floor(makerUsd * mul3) / mul3;
}
}
decimal supportedShares = Math.Floor((makerUsd / priceRounded) * multiplier) / multiplier;
if (makerUsd >= 1.0m && supportedShares >= takerShares)
break;
takerShares += step;
}
finalTakerAmountRaw = Math.Round(takerShares * 1_000_000m);
finalMakerAmountRaw = Math.Round(makerUsd * 1_000_000m);
executedShares = takerShares;
executedUsdc = makerUsd;
}
else
{
decimal sharesRaw = investAmountUsd / priceRounded;
decimal multiplier = (decimal)Math.Pow(10, sizeDec);
decimal makerShares = Math.Floor(sharesRaw * multiplier) / multiplier;
// Polymarket STRICTLY enforces a 5 share minimum for ANY sell order on the CLOB
if (makerShares < 5.0m) return (-1, -1, 0, 0);
decimal takerUsd = makerShares * priceRounded;
int actDec = BitConverter.GetBytes(decimal.GetBits(takerUsd)[3])[2];
if (actDec > amtDec)
{
decimal mul2 = (decimal)Math.Pow(10, amtDec + 4);
takerUsd = Math.Ceiling(takerUsd * mul2) / mul2;
if (BitConverter.GetBytes(decimal.GetBits(takerUsd)[3])[2] > amtDec)
{
decimal mul3 = (decimal)Math.Pow(10, amtDec);
takerUsd = Math.Floor(takerUsd * mul3) / mul3;
}
}
finalMakerAmountRaw = Math.Round(makerShares * 1_000_000m);
finalTakerAmountRaw = Math.Round(takerUsd * 1_000_000m);
executedShares = makerShares;
executedUsdc = takerUsd;
}
return (executedShares, executedUsdc, finalMakerAmountRaw, finalTakerAmountRaw);
}
/// <summary>
/// Executes a native EIP-712 signed order (default Fill-Or-Kill)
/// </summary>
public async Task<string> PlaceOrderAsync(AccountState account, string tokenId, string sideStr, decimal investAmountUsd, decimal limitPrice, string orderType = "FOK", bool debugPayloadLog = false, bool isNegRisk = false, int actualFeeBps = 0, decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
if (string.IsNullOrEmpty(account.PrivateKey) || string.IsNullOrEmpty(account.ApiKey))
return "Error: Missing API or Private Keys";
try
{
var signer = new Eip712TypedDataSigner();
var key = new EthECKey(account.PrivateKey);
var typedData = new TypedData<CtfDomain>
{
Domain = new CtfDomain
{
Name = "Polymarket CTF Exchange",
Version = "1",
ChainId = ChainId,
VerifyingContract = isNegRisk ? "0xC5d563A36AE78145C45a50134d48A1215220f80a" : "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E"
},
Types = Nethereum.ABI.EIP712.MemberDescriptionFactory.GetTypesMemberDescription(typeof(CtfDomain), typeof(CtfOrder)),
PrimaryType = "Order"
};
var amounts = CalculateExactOrderAmounts(investAmountUsd, limitPrice, limitPrice, sideStr, orderType, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
if (amounts.shares <= 0)
return $"Mathematical tick size error: Balance too small to meet fractional quantum limit for exact price matching";
decimal makerAmountRaw = amounts.makerRaw;
decimal takerAmountRaw = amounts.takerRaw;
System.Numerics.BigInteger parsedTokenId;
if (tokenId.StartsWith("0x") || tokenId.Any(c => "abcdefABCDEF".Contains(c)))
{
parsedTokenId = new Nethereum.Hex.HexTypes.HexBigInteger(tokenId.StartsWith("0x") ? tokenId : "0x" + tokenId).Value;
}
else
{
parsedTokenId = System.Numerics.BigInteger.Parse(tokenId);
}
var ctfOrder = new CtfOrder
{
Salt = GenerateSalt(),
Maker = account.WalletAddress,
Signer = key.GetPublicAddress(),
Taker = "0x0000000000000000000000000000000000000000",
TokenId = parsedTokenId,
MakerAmount = new System.Numerics.BigInteger(makerAmountRaw),
TakerAmount = new System.Numerics.BigInteger(takerAmountRaw),
Expiration = 0,
Nonce = 0,
FeeRateBps = new System.Numerics.BigInteger(actualFeeBps),
Side = sideStr.ToUpper() == "BUY" ? (byte)0 : (byte)1,
SignatureType = 2
};
string signature = signer.SignTypedDataV4(ctfOrder, typedData, key);
var reqBody = new
{
order = new
{
salt = (long)ctfOrder.Salt,
maker = ctfOrder.Maker.ToLower(),
signer = ctfOrder.Signer.ToLower(),
taker = ctfOrder.Taker.ToLower(),
tokenId = ctfOrder.TokenId.ToString(),
makerAmount = ctfOrder.MakerAmount.ToString(),
takerAmount = ctfOrder.TakerAmount.ToString(),
expiration = ctfOrder.Expiration.ToString(),
nonce = ctfOrder.Nonce.ToString(),
feeRateBps = ctfOrder.FeeRateBps.ToString(),
side = ctfOrder.Side == 0 ? "BUY" : "SELL",
signatureType = ctfOrder.SignatureType,
signature = signature
},
owner = account.ApiKey,
orderType = orderType
};
string jsonBody = JsonSerializer.Serialize(reqBody);
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string requestPath = "/order";
string hmacSig = GenerateHmacSignature(account.ApiSecret, timestamp, "POST", requestPath, jsonBody);
using var request = new HttpRequestMessage(HttpMethod.Post, $"{ClobHost}{requestPath}");
var keyObj = new EthECKey(account.PrivateKey.Replace("0x", ""));
request.Headers.Add("POLY_ADDRESS", keyObj.GetPublicAddress());
request.Headers.Add("POLY_API_KEY", account.ApiKey);
request.Headers.Add("POLY_TIMESTAMP", timestamp);
request.Headers.Add("POLY_SIGNATURE", hmacSig);
request.Headers.Add("POLY_PASSPHRASE", account.ApiPassphrase);
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
if (debugPayloadLog)
{
_logger.Debug($"[CLOB-PAYLOAD] -> {jsonBody}");
}
using var response = await _httpClient.SendAsync(request);
var responseContent = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
bool isFokFail = responseContent.Contains("FOK orders are fully filled or killed");
if (isFokFail && sideStr == "BUY")
{
// Dampen FOK failed BUY logs. Usually means target price/liquidity not met for full copy size.
// We skip it silently.
return "SKIPPED_LIQUIDITY";
}
lock (_fileLock)
{
System.IO.File.WriteAllText("last_invalid_payload.json", jsonBody);
}
if (isFokFail && sideStr == "SELL")
{
_logger.Warning($"Liquidität für FOK SELL reicht nicht aus. (Orderbook Size limit). Rest-Shares bleiben erhalten.");
return "Nicht genügend Liquidität für vollumfänglichen Verkauf auf diesem Preisniveau (FOK).";
}
else
{
var tickMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"breaks minimum tick size rule: ([\d\.]+)");
if (tickMatch.Success && overrideTickSize == null)
{
if (decimal.TryParse(tickMatch.Groups[1].Value, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal newTickSize))
{
_logger.Info($"🔄 Automatische Anpassung an Markt Tick-Size ({newTickSize}). Order wird erneut berechnet und platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, newTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
}
var decMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"maker amount supports a max accuracy of (\d+) decimals, taker amount a max of (\d+) decimals");
if (decMatch.Success && overrideMakerDecimals == null)
{
if (int.TryParse(decMatch.Groups[1].Value, out int newMaker) && int.TryParse(decMatch.Groups[2].Value, out int newTaker))
{
_logger.Info($"🔄 Automatische Anpassung an Dezimalregeln (Maker: {newMaker}, Taker: {newTaker}). Order wird neu berechnet...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, newMaker, newTaker);
}
}
// Check if error is "invalid fee rate" -> Extract required fee -> Retry!
var match = System.Text.RegularExpressions.Regex.Match(responseContent, @"invalid fee rate \(\d+\), current market's (?:taker|maker) fee: (\d+)");
if (match.Success && actualFeeBps == 0) // Only retry once
{
if (int.TryParse(match.Groups[1].Value, out int newFeeBps))
{
_logger.Info($"🔄 Automatische Anpassung an Fee Rate ({newFeeBps} bps). Order wird erneut platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, orderType, debugPayloadLog, isNegRisk, newFeeBps, overrideTickSize);
}
}
// Check if error is "Size lower than minimum 5" -> Fallback to MARKET
var sizeMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"Size \([\d\.]+\) lower than the minimum: (\d+)");
if (sizeMatch.Success)
{
if (decimal.TryParse(sizeMatch.Groups[1].Value, out decimal minReq))
{
if (orderType != "MARKET")
{
_logger.Info($"🔄 Automatische Anpassung an Minimum Size Limit (Limitorder < {minReq}). Order wird als MARKET platziert...");
return await PlaceOrderAsync(account, tokenId, sideStr, investAmountUsd, limitPrice, "MARKET", debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
else if (sideStr == "SELL")
{
_logger.Warning($"Verkauf von unter {minReq} Shares auf Polymarket nicht möglich (Orderbook Limit). Position muss aufgestockt werden oder auslaufen.");
return $"Börsenlimit: Mindestens {minReq} Shares erforderlich.";
}
}
}
var balMatch = System.Text.RegularExpressions.Regex.Match(responseContent, @"balance: (\d+), sum of active orders: (\d+)");
if (balMatch.Success && sideStr == "SELL")
{
if (decimal.TryParse(balMatch.Groups[1].Value, out decimal totalBal) && decimal.TryParse(balMatch.Groups[2].Value, out decimal activeOrders))
{
decimal availableSharesRaw = totalBal - activeOrders;
decimal availableShares = availableSharesRaw / 1_000_000m;
decimal requiredShares = investAmountUsd / limitPrice;
if (availableShares > 0 && Math.Abs(availableShares - requiredShares) > 0.001m && availableShares < requiredShares)
{
decimal newInvestAmount = availableShares * limitPrice;
_logger.Info($"🔄 Automatische Anpassung an verfügbare Shares (Aktive Orders blockieren {activeOrders / 1000000m} Shares). Verkaufe restliche {availableShares} Shares...");
return await PlaceOrderAsync(account, tokenId, sideStr, newInvestAmount, limitPrice, orderType, debugPayloadLog, isNegRisk, actualFeeBps, overrideTickSize, overrideMakerDecimals, overrideTakerDecimals);
}
}
}
_logger.Error($"CLOB Order Error ({response.StatusCode}): {responseContent}");
}
return "ERROR";
}
if (response.IsSuccessStatusCode)
{
_logger.Info($"✅ Order Platzierung Erfolgreich! {sideStr} @ {limitPrice:F3}");
return "OK";
}
else
{
_logger.Error($"❌ Order Fehler: {response.StatusCode} - {responseContent}");
return responseContent;
}
}
catch (Exception ex)
{
_logger.Error($"PlaceFokOrderAsync Runtime Fehler: {ex.Message}");
return ex.Message;
}
}
}
}
-284
View File
@@ -1,284 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTraderSharp.Models;
using LiteDB;
namespace PolyTraderSharp.Services
{
public class PolymarketWssClient : BackgroundService
{
private const string MarketWssUrl = "wss://ws-subscriptions-clob.polymarket.com/ws/market";
private readonly TradingState _state;
private readonly ServerSettings _settings;
private readonly PolymarketClobClient _clob;
private readonly TerminalLogger _logger;
private readonly ILiteDatabase _db;
// Tracking rate limits for auto redeem to avoid spam
private readonly ConcurrentDictionary<string, DateTime> _lastRedeemAttempt = new();
public PolymarketWssClient(
TradingState state,
ServerSettings settings,
PolymarketClobClient clob,
TerminalLogger logger,
ILiteDatabase db)
{
_state = state;
_settings = settings;
_clob = clob;
_logger = logger;
_db = db;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (!_settings.UsePolymarketWebsockets || _state.GlobalTradingPaused)
{
await Task.Delay(5000, stoppingToken);
continue;
}
try
{
await ConnectMarketWssAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.Warning($"Polymarket WSS disconnected ({ex.Message}). Retrying in 5s...");
await Task.Delay(5000, stoppingToken);
}
}
}
private async Task ConnectMarketWssAsync(CancellationToken stoppingToken)
{
using var ws = new ClientWebSocket();
_logger.Info("Connecting to Polymarket WSS (Market Stream) for live pricing...");
await ws.ConnectAsync(new Uri(MarketWssUrl), stoppingToken);
_logger.Info("✅ Polymarket Market WSS Connected.");
var allSubscriptions = new HashSet<string>();
var subscriptionTask = Task.Run(async () =>
{
while (ws.State == WebSocketState.Open && !stoppingToken.IsCancellationRequested && _settings.UsePolymarketWebsockets)
{
var neededAssets = new HashSet<string>();
foreach (var acc in _state.Accounts.Values.Where(a => a.IsActive))
foreach (var token in acc.OpenPositions.Keys)
neededAssets.Add(token);
var missing = neededAssets.Except(allSubscriptions).ToList();
if (missing.Any())
{
var req = new
{
assets_ids = missing,
type = "market"
};
var json = System.Text.Json.JsonSerializer.Serialize(req);
var bytes = Encoding.UTF8.GetBytes(json);
await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, stoppingToken);
foreach (var m in missing) allSubscriptions.Add(m);
_logger.Info($"📡 Polymarket WSS: Subscribed to {missing.Count} new assets. Total: {allSubscriptions.Count}");
}
await Task.Delay(5000, stoppingToken); // Check for new positions every 5s
}
}, stoppingToken);
var buffer = new byte[1024 * 64]; // 64kb buffer
while (ws.State == WebSocketState.Open && !stoppingToken.IsCancellationRequested && _settings.UsePolymarketWebsockets)
{
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), stoppingToken);
if (result.MessageType == WebSocketMessageType.Close) break;
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
if (!string.IsNullOrEmpty(message))
{
try { ProcessMarketMessage(message); } catch { }
}
}
}
private void ProcessMarketMessage(string jsonStr)
{
try
{
using var doc = JsonDocument.Parse(jsonStr);
var root = doc.RootElement;
if (!root.TryGetProperty("event_type", out var evtTypeProp)) return;
var eventType = evtTypeProp.GetString();
if (eventType == "price_change")
{
if (root.TryGetProperty("price_changes", out var changes) && changes.ValueKind == JsonValueKind.Array)
{
foreach (var change in changes.EnumerateArray())
{
if (change.TryGetProperty("asset_id", out var assetIdProp) &&
change.TryGetProperty("price", out var priceProp))
{
string assetId = assetIdProp.GetString()!;
decimal.TryParse(priceProp.GetString(), out decimal price);
decimal bestBid = price;
if (change.TryGetProperty("best_bid", out var bidProp) && decimal.TryParse(bidProp.GetString(), out decimal bBid))
{
if (bBid > 0) bestBid = bBid;
}
UpdateAssetPriceAndCheckAutoRedeem(assetId, bestBid);
}
}
}
}
else if (eventType == "last_trade_price")
{
if (root.TryGetProperty("asset_id", out var assetIdProp) && root.TryGetProperty("price", out var priceProp))
{
string assetId = assetIdProp.GetString()!;
decimal.TryParse(priceProp.GetString(), out decimal price);
UpdateAssetPriceAndCheckAutoRedeem(assetId, price);
}
}
}
catch { }
}
private void UpdateAssetPriceAndCheckAutoRedeem(string assetId, decimal price)
{
if (price <= 0 || string.IsNullOrEmpty(assetId)) return;
foreach (var acc in _state.Accounts.Values)
{
if (acc.OpenPositions.TryGetValue(assetId, out var pos))
{
pos.CurrentPrice = price;
pos.CurrentValueUsd = pos.Size * price;
// Execute Auto-Redeem if config conditions are met
if (acc.PreRedeemLimit > 0 && price >= acc.PreRedeemLimit && acc.IsActive)
{
string redeemKey = $"{acc.AccountId}_{assetId}";
// Spam protection: try only once every 10 seconds per position
if (_lastRedeemAttempt.TryGetValue(redeemKey, out var lastAttempt) && (DateTime.UtcNow - lastAttempt).TotalSeconds < 10)
continue;
_lastRedeemAttempt[redeemKey] = DateTime.UtcNow;
if (!acc.IsDemo && _state.LiveTradingMode == TradingMode.Active)
{
_logger.Trade($"🚨 [AUTO REDEEM] {acc.Name} | {pos.MarketQuestion} | Preis >= {acc.PreRedeemLimit}");
// Best effort non-blocking
_ = Task.Run(async () => await ExecuteAutoRedeemLive(acc, pos, price));
}
else if (acc.IsDemo && _state.DemoTradingMode == TradingMode.Active)
{
_logger.Trade($"🚨 [AUTO REDEEM DEMO] {acc.Name} | {pos.MarketQuestion} | Preis >= {acc.PreRedeemLimit}");
_ = Task.Run(() => ExecuteAutoRedeemDemo(acc, pos, price));
}
}
}
}
}
private async Task ExecuteAutoRedeemLive(AccountState acc, Position pos, decimal triggerPrice)
{
if (pos.Size < 5.0m)
{
_logger.Warning($"[AUTO REDEEM] Position {pos.MarketQuestion} zu klein für Limit Order (< 5 Shares). Wird ignoriert um Error-Spam zu vermeiden.");
return;
}
try
{
// The user explicitly requested an exact GTC order using the configured PreRedeemLimit, without slippage
decimal expectedFillPrice = acc.PreRedeemLimit;
decimal amountUsdc = Math.Max(pos.Size * expectedFillPrice, 0.01m);
// Fire and forget SELL via ClobClient
var result = await _clob.PlaceOrderAsync(acc, pos.TokenId, "SELL", amountUsdc, expectedFillPrice, "GTC", false, false);
if (result == "OK")
{
_logger.Info($"✅ Auto-Redeem Sell sent for {acc.Name} at exact Limit {expectedFillPrice:F3} USD (GTC).");
// Assume it's an open matching order. Clob/Market API will sync actual status later.
if (acc.OpenPositions.TryRemove(pos.TokenId, out _)) {
// Live position updates handle ClosedTrade DB insertion elsewhere normally via Sync
}
}
else
{
_logger.Error($"❌ Auto-Redeem failed or rejected: {result}.");
}
}
catch (Exception ex)
{
_logger.Error($"Auto Redeem Exception: {ex.Message}");
}
}
private void ExecuteAutoRedeemDemo(AccountState acc, Position pos, decimal triggerPrice)
{
try
{
if (acc.OpenPositions.TryRemove(pos.TokenId, out _))
{
_db.GetCollection<Position>($"demo_positions_{acc.AccountId}").Delete(pos.TokenId);
decimal exactLimitPrice = acc.PreRedeemLimit;
decimal exitUsd = pos.Size * exactLimitPrice;
decimal realizedPnl = exitUsd - pos.AmountUsd;
_state.GlobalPnl += realizedPnl;
acc.UpdateBalance(acc.AvailableBalance + exitUsd);
var ct = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = acc.AccountId,
IsDemo = true,
MarketSlug = pos.MarketSlug,
MarketQuestion = pos.MarketQuestion,
TokenId = pos.TokenId,
Outcome = pos.Outcome,
Side = "SELL",
EntryPrice = pos.EntryPrice,
ExitPrice = exactLimitPrice,
Size = pos.Size,
RealizedPnl = realizedPnl,
PnlPercent = pos.AmountUsd > 0 ? (realizedPnl / pos.AmountUsd * 100m) : 0m,
OpenedAt = pos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = "Pre Redeem"
};
_db.GetCollection<ClosedTrade>("closed_trades").Insert(ct);
_db.GetCollection<AccountState>("accounts").Upsert(acc);
_logger.Trade($"✅ [AUTO REDEEM DEMO ERFOLGREICH] {pos.MarketQuestion} | Exit: {pos.Size:F2} @ {exactLimitPrice:F3} | PnL: ${realizedPnl:F2}");
}
}
catch (Exception ex)
{
_logger.Error($"Demo Auto Redeem failed: {ex.Message}");
}
}
}
}
-550
View File
@@ -1,550 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
public class TraderMonitorService : BackgroundService
{
private readonly TradingState _state;
private readonly PolymarketApiService _api;
private readonly ChannelWriter<CopySignal> _signalWriter;
private readonly ChannelWriter<ClosedTrade> _closedTradeWriter;
private readonly TerminalLogger _logger;
// Prevents duplicates. Fast O(1) lookup cache to prevent DB spam.
private readonly ConcurrentDictionary<string, DateTime> _processedTxHashes = new();
private DateTime _lastHashCleanup = DateTime.UtcNow;
private readonly ConcurrentDictionary<string, bool> _processedClosures = new();
private readonly ConcurrentDictionary<string, DateTime> _lastPolled = new();
private DateTime _lastLivePoll = DateTime.MinValue;
public TraderMonitorService(
TradingState state,
PolymarketApiService api,
ChannelWriter<CopySignal> signalWriter,
ChannelWriter<ClosedTrade> closedTradeWriter,
TerminalLogger logger)
{
_state = state;
_api = api;
_signalWriter = signalWriter;
_closedTradeWriter = closedTradeWriter;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.Info("TraderMonitorService started background API priority polling...");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await PollActiveTradersAsync(stoppingToken);
// Live Accounts open positions sync (Runs every 30s instead of slamming API constantly)
if ((DateTime.UtcNow - _lastLivePoll).TotalSeconds > 30)
{
await PollLiveAccountsAsync(stoppingToken);
await PollDemoExpirationsAsync(stoppingToken);
_lastLivePoll = DateTime.UtcNow;
}
}
catch (Exception ex)
{
_logger.Error($"TraderMonitor polling error: {ex.Message}");
}
// Global Engine Tick (dynamic queue evaluation)
await Task.Delay(1000, stoppingToken);
}
}
private async Task PollActiveTradersAsync(CancellationToken ct)
{
// Only process ACTIVE trader copies if not paused/inactive
if (_state.GlobalTradingPaused ||
(_state.DemoTradingMode == TradingMode.Inactive && _state.LiveTradingMode == TradingMode.Inactive))
{
return;
}
var activeTraders = _state.Traders.Values.Where(t => t.IsActive).ToList();
if (activeTraders.Count == 0) return;
var now = DateTime.UtcNow;
var toPoll = new List<TrackedTrader>();
bool isWssHealthy = _state.IsAlchemyHealthy;
// Calculate Dynamic Priorities
// Data API rate limit: 1000 req/10s (general).
// Worst case: 30 traders × high prio (3s) = ~100 req/10s = 10% capacity.
// With medium prio at 10s and batches of 10: well within limits.
foreach (var trader in activeTraders)
{
if (!_lastPolled.TryGetValue(trader.WalletAddress, out var lastPoll))
lastPoll = DateTime.MinValue;
double secondsSinceLastPoll = (now - lastPoll).TotalSeconds;
int requiredInterval = 10; // Medium Prio Default (Data API: 1000/10s headroom)
if (isWssHealthy)
{
// If WSS is healthy, fall back to safety-net polling
requiredInterval = 60; // 1 minute (was 2 min)
}
else
{
if (trader.TotalTrades > 20 || trader.Winrate30t >= 60.0)
requiredInterval = 3; // High Prio (unchanged — already fast)
else if (trader.TotalTrades < 5)
requiredInterval = 30; // Low Prio (was 120s)
}
if (secondsSinceLastPoll >= requiredInterval)
{
toPoll.Add(trader);
}
}
if (toPoll.Count == 0) return;
// Batch Execution (Max 10 Concurrent Requests to respect API limits)
int batchSize = 10;
for (int i = 0; i < toPoll.Count; i += batchSize)
{
if (ct.IsCancellationRequested) break;
var batch = toPoll.Skip(i).Take(batchSize);
var tasks = batch.Select(async trader =>
{
_lastPolled[trader.WalletAddress] = DateTime.UtcNow;
System.Diagnostics.Stopwatch? sw = null;
if (_state.DebugPollingLog) sw = System.Diagnostics.Stopwatch.StartNew();
var activity = await _api.GetTraderActivityAsync(trader.WalletAddress, limit: 50);
if (_state.DebugPollingLog && sw != null)
{
sw.Stop();
_logger.Debug($"[API-Profiler] Activity-Request für Trader {trader.DisplayName} dauerte {sw.ElapsedMilliseconds} ms.");
}
foreach (var act in activity)
{
ProcessActivityItem(act, trader);
}
});
await Task.WhenAll(tasks);
await Task.Delay(200, ct); // Tiny 200ms breath between batches
}
// Cleanup old hashes periodically (keep for 24 hours to prevent ANY duplicates)
if ((DateTime.UtcNow - _lastHashCleanup).TotalHours > 1)
{
var cutoff = DateTime.UtcNow.AddHours(-24);
var expired = _processedTxHashes.Where(x => x.Value < cutoff).Select(x => x.Key).ToList();
foreach (var k in expired) _processedTxHashes.TryRemove(k, out _);
_lastHashCleanup = DateTime.UtcNow;
}
}
/// <summary>
/// Triggered instantly by the AlchemyWebsocketService when an EVM TransferSingle is detected.
/// </summary>
public void TriggerManualPoll(string walletAddress)
{
var trader = _state.Traders.Values.FirstOrDefault(t => t.WalletAddress.Equals(walletAddress, StringComparison.OrdinalIgnoreCase));
if (trader != null && trader.IsActive)
{
// Force an immediate poll on the next tick by artificially advancing the last poll date
_lastPolled[trader.WalletAddress] = DateTime.MinValue;
}
}
private async Task PollDemoExpirationsAsync(CancellationToken ct)
{
var demoAccounts = _state.Accounts.Values.Where(a => a.IsDemo && a.IsActive).ToList();
if (demoAccounts.Count == 0) return;
foreach (var acc in demoAccounts)
{
if (ct.IsCancellationRequested) break;
// Check positions that are near expiry, recently expired, or have no expiry but have a slug
var checkPositions = acc.OpenPositions.Values.Where(p =>
!string.IsNullOrEmpty(p.MarketSlug) &&
(
// Has expiry and is within check window (-1 day to +30 days)
(p.ExpiryDate.HasValue &&
(DateTime.UtcNow - p.ExpiryDate.Value).TotalDays > -1 &&
(DateTime.UtcNow - p.ExpiryDate.Value).TotalDays < 30)
||
// No expiry date at all — always check via API
!p.ExpiryDate.HasValue
)).ToList();
foreach (var pos in checkPositions)
{
var (isClosed, isWinner) = await _api.CheckMarketResolutionAsync(pos.MarketSlug, pos.TokenId);
if (isClosed)
{
decimal exitPrice = isWinner ? 1.0m : 0.0m;
_logger.Info($"🏆 Demo Market {pos.MarketQuestion} aufgelöst! Auszahlung: ${(exitPrice * pos.Size):F2}");
var signal = new CopySignal
{
TraderId = 0,
TokenId = pos.TokenId,
MarketSlug = pos.MarketSlug,
MarketQuestion = pos.MarketQuestion,
Outcome = pos.Outcome,
Side = "SELL",
Price = exitPrice,
Size = pos.Size,
Timestamp = DateTime.UtcNow,
Reason = "Market Resolved"
};
_signalWriter.TryWrite(signal);
await Task.Delay(500, ct);
}
}
}
}
private async Task PollLiveAccountsAsync(CancellationToken ct)
{
// Always sync live positions so the Dashboard UI accurately reflects open PnL and portfolio balance
var liveAccounts = _state.Accounts.Values.Where(a => !a.IsDemo && a.IsActive && !string.IsNullOrEmpty(a.WalletAddress)).ToList();
if (liveAccounts.Count == 0) return;
foreach (var acc in liveAccounts)
{
if (ct.IsCancellationRequested) break;
var posList = await _api.SyncOpenPositionsAsync(acc.WalletAddress);
if (posList.Count == 0) continue;
var currentTokens = new HashSet<string>();
foreach (var posJson in posList)
{
string asset = posJson.TryGetProperty("asset", out var ap) ? ap.GetString() ?? "" : "";
if (string.IsNullOrEmpty(asset)) continue;
currentTokens.Add(asset);
string slug = posJson.TryGetProperty("slug", out var sp) ? sp.GetString() ?? "" : "";
string title = posJson.TryGetProperty("title", out var tp) ? tp.GetString() ?? "" : "";
string opp = posJson.TryGetProperty("oppositeOutcome", out var op) ? op.GetString() ?? "" : "No";
decimal size = 0m, entryPrice = 0m, amountUsd = 0m, curPrice = 0m, curValue = 0m;
if (posJson.TryGetProperty("size", out var sprop)) size = ParseDecimal(sprop);
if (posJson.TryGetProperty("avgPrice", out var aprop)) entryPrice = ParseDecimal(aprop);
// Critical Fix: "totalBought" is size. "initialValue" is original USD investment cost.
if (posJson.TryGetProperty("initialValue", out var tbprop)) amountUsd = ParseDecimal(tbprop);
if (posJson.TryGetProperty("curPrice", out var cpprop)) curPrice = ParseDecimal(cpprop);
if (posJson.TryGetProperty("currentValue", out var cvprop)) curValue = ParseDecimal(cvprop);
DateTime? expiry = null;
if (posJson.TryGetProperty("endDate", out var ep))
{
if (DateTime.TryParse(ep.GetString(), out var ed)) expiry = DateTime.SpecifyKind(ed.Date, DateTimeKind.Utc);
}
if (acc.OpenPositions.TryGetValue(asset, out var existing))
{
existing.Size = size;
existing.EntryPrice = entryPrice;
existing.AmountUsd = amountUsd;
existing.CurrentPrice = curPrice;
existing.CurrentValueUsd = curValue;
if (expiry.HasValue) existing.ExpiryDate = expiry;
}
else
{
var newPos = new Position
{
TokenId = asset,
MarketSlug = slug,
MarketQuestion = title,
Outcome = opp == "Yes" ? "No" : "Yes",
SourceTraderName = "Live Sync",
Side = "BUY",
Size = size,
EntryPrice = entryPrice,
AmountUsd = amountUsd,
CurrentPrice = curPrice,
CurrentValueUsd = curValue,
ExpiryDate = expiry
};
acc.OpenPositions.TryAdd(asset, newPos);
_logger.Info($"🌐 Live Position erkannt: {title} ({newPos.Outcome}) - ${amountUsd} - Account: {acc.Name}");
}
}
var tokensToRemove = acc.OpenPositions
.Where(kvp => !currentTokens.Contains(kvp.Key))
.Where(kvp => (DateTime.UtcNow - kvp.Value.OpenedAt).TotalMinutes > 5)
.Select(kvp => kvp.Key)
.ToList();
if (tokensToRemove.Count > 0)
{
var closedPositions = await _api.SyncClosedPositionsAsync(acc.WalletAddress, 50);
foreach (var k in tokensToRemove)
{
if (acc.OpenPositions.TryRemove(k, out var removedPos))
{
JsonElement? matchedClose = null;
foreach (var cm in closedPositions)
{
if (cm.TryGetProperty("asset", out var ap) && ap.GetString() == k)
{
matchedClose = cm;
break;
}
}
if (matchedClose.HasValue)
{
decimal realizedPnl = 0m;
if (matchedClose.Value.TryGetProperty("realizedPnl", out var rPnlProp)) realizedPnl = ParseDecimal(rPnlProp);
_state.GlobalPnl += realizedPnl;
decimal exitPrice = removedPos.Size > 0 ? (removedPos.AmountUsd + realizedPnl) / removedPos.Size : 0m;
string duplicateKey = $"{acc.AccountId}_{removedPos.TokenId}";
if (!_processedClosures.ContainsKey(duplicateKey))
{
_logger.Info($"🏆 Live Market {removedPos.MarketQuestion} geschlossen! PnL: ${(realizedPnl):F2}");
var ctRecord = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = acc.AccountId,
SourceTraderId = removedPos.SourceTraderId,
IsDemo = false,
MarketSlug = removedPos.MarketSlug,
MarketQuestion = removedPos.MarketQuestion,
Outcome = removedPos.Outcome,
Side = "SELL",
EntryPrice = removedPos.EntryPrice,
ExitPrice = exitPrice,
Size = removedPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = removedPos.AmountUsd > 0 ? (realizedPnl / removedPos.AmountUsd * 100m) : 0m,
OpenedAt = removedPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = "API Closed"
};
_processedClosures.TryAdd(duplicateKey, true);
_closedTradeWriter.TryWrite(ctRecord);
}
}
else
{
var (isClosed, isWinner) = await _api.CheckMarketResolutionAsync(removedPos.MarketSlug, removedPos.TokenId);
if (isClosed)
{
decimal exitPrice = isWinner ? 1.0m : 0.0m;
decimal exitUsd = removedPos.Size * exitPrice;
decimal realizedPnl = exitUsd - removedPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
string duplicateKey = $"{acc.AccountId}_{removedPos.TokenId}";
if (!_processedClosures.ContainsKey(duplicateKey))
{
_logger.Info($"🏆 Live Market {removedPos.MarketQuestion} aufgelöst (Fallback)! Auszahlung: ${(exitPrice * removedPos.Size):F2}");
var ctRecord = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = acc.AccountId,
SourceTraderId = removedPos.SourceTraderId,
IsDemo = false,
MarketSlug = removedPos.MarketSlug,
MarketQuestion = removedPos.MarketQuestion,
Outcome = removedPos.Outcome,
Side = "SELL",
EntryPrice = removedPos.EntryPrice,
ExitPrice = exitPrice,
Size = removedPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = removedPos.AmountUsd > 0 ? (realizedPnl / removedPos.AmountUsd * 100m) : 0m,
OpenedAt = removedPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = "API Resolved"
};
_processedClosures.TryAdd(duplicateKey, true);
_closedTradeWriter.TryWrite(ctRecord);
}
if (isWinner)
{
/*
* DEATIVIERT: Automatischer Redeem via Python Script ist vorerst pausiert.
* User kann die gewonnenen Shares per Klick im Polymarket Web-Interface redeemen.
* Die Datenbank hat die PnL trotzdem bereits korrekt aufgezeichnet!
*
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "python",
Arguments = $"redeem_markets.py {removedPos.TokenId} {acc.ApiKey} {acc.PrivateKey} {acc.ApiPassphrase}",
UseShellExecute = false,
CreateNoWindow = true
});
_logger.Info($"Python Redeem Script für Token {removedPos.TokenId} asynchron ausgeführt.");
}
catch (Exception ex)
{
_logger.Error($"Fehler beim Starten von redeem_markets.py: {ex.Message}");
}
*/
_logger.Info($"🏆 Token {removedPos.TokenId} bereit für manuellen Redeem via Polymarket-Webseite. (P&L wurde bereits gebucht).");
}
}
else
{
_logger.Info($"🌐 Live Position {removedPos.MarketQuestion} (Ext. Verkauft/Wartend)");
}
}
}
}
}
await Task.Delay(500, ct);
}
}
private decimal ParseDecimal(JsonElement prop)
{
if (prop.ValueKind == JsonValueKind.Number) return prop.GetDecimal();
if (prop.ValueKind == JsonValueKind.String && decimal.TryParse(prop.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) return parsed;
return 0m;
}
private void ProcessActivityItem(JsonElement act, TrackedTrader trader)
{
try
{
string txHash = act.GetProperty("transactionHash").GetString() ?? "";
if (string.IsNullOrEmpty(txHash) || _processedTxHashes.ContainsKey(txHash))
return; // Duplicate or invalid
string type = act.GetProperty("type").GetString() ?? "";
if (type.ToUpper() != "TRADE" && type.ToUpper() != "BUY" && type.ToUpper() != "SELL")
return;
string sideStr = type; // Fallback to type
if (act.TryGetProperty("side", out var sideProp) && sideProp.ValueKind == JsonValueKind.String) sideStr = sideProp.GetString() ?? sideStr;
else if (act.TryGetProperty("action", out var actionProp) && actionProp.ValueKind == JsonValueKind.String) sideStr = actionProp.GetString() ?? sideStr;
else if (act.TryGetProperty("tradeType", out var ttProp) && ttProp.ValueKind == JsonValueKind.String) sideStr = ttProp.GetString() ?? sideStr;
string asset = "";
if (act.TryGetProperty("asset", out var assetProp) && assetProp.ValueKind == JsonValueKind.String) asset = assetProp.GetString() ?? "";
if (string.IsNullOrEmpty(asset) && act.TryGetProperty("tokenId", out var tidProp) && tidProp.ValueKind == JsonValueKind.String) asset = tidProp.GetString() ?? "";
if (string.IsNullOrEmpty(asset) && act.TryGetProperty("token_id", out var t_idProp) && t_idProp.ValueKind == JsonValueKind.String) asset = t_idProp.GetString() ?? "";
if (string.IsNullOrEmpty(asset) && act.TryGetProperty("conditionId", out var cidProp) && cidProp.ValueKind == JsonValueKind.String) asset = cidProp.GetString() ?? "";
if (string.IsNullOrEmpty(asset) && act.TryGetProperty("condition_id", out var c_idProp) && c_idProp.ValueKind == JsonValueKind.String) asset = c_idProp.GetString() ?? "";
decimal price = 0m;
if (act.TryGetProperty("price", out var priceProp))
{
if (priceProp.ValueKind == JsonValueKind.Number) price = priceProp.GetDecimal();
else if (priceProp.ValueKind == JsonValueKind.String) decimal.TryParse(priceProp.GetString(), out price);
}
decimal size = 0m;
if (act.TryGetProperty("size", out var sizeProp))
{
if (sizeProp.ValueKind == JsonValueKind.Number) size = sizeProp.GetDecimal();
else if (sizeProp.ValueKind == JsonValueKind.String) decimal.TryParse(sizeProp.GetString(), out size);
}
// Parse timestamp to prevent old trades
DateTime tradeTs = DateTime.UtcNow;
if (act.TryGetProperty("timestamp", out var tsProp))
{
if (tsProp.ValueKind == JsonValueKind.Number) // Unix
tradeTs = DateTimeOffset.FromUnixTimeSeconds(tsProp.GetInt64()).UtcDateTime;
else if (tsProp.ValueKind == JsonValueKind.String && DateTime.TryParse(tsProp.GetString(), out var dt))
tradeTs = dt.ToUniversalTime();
}
// If trade is older than 120 seconds, skip
if ((DateTime.UtcNow - tradeTs).TotalSeconds > 120)
{
// Still add to seen so we don't re-parse it
_processedTxHashes.TryAdd(txHash, DateTime.UtcNow);
return;
}
_processedTxHashes.TryAdd(txHash, DateTime.UtcNow);
var displayQuestion = "";
if (act.TryGetProperty("title", out var titleProp)) displayQuestion = titleProp.GetString() ?? "";
var signal = new CopySignal
{
TraderId = trader.Id,
TokenId = asset,
ConditionId = "",
MarketSlug = act.TryGetProperty("slug", out var sp) ? sp.GetString() ?? "" : (act.TryGetProperty("marketSlug", out var msp) ? msp.GetString() ?? "" : ""),
Side = sideStr.ToUpper().Contains("SELL") ? "SELL" : "BUY",
Price = price,
Size = size,
Timestamp = tradeTs,
MarketQuestion = displayQuestion,
Outcome = act.TryGetProperty("outcome", out var outProp) ? outProp.GetString() ?? "" : "",
Reason = sideStr.ToUpper().Contains("SELL") ? "Master Trader Sold" : ""
};
// Parse endDate from activity JSON for market expiry
if (act.TryGetProperty("endDate", out var endDateProp))
{
if (endDateProp.ValueKind == JsonValueKind.String && DateTime.TryParse(endDateProp.GetString(), null, System.Globalization.DateTimeStyles.RoundtripKind, out var endDt))
signal.EndDate = endDt.ToUniversalTime();
else if (endDateProp.ValueKind == JsonValueKind.Number)
signal.EndDate = DateTimeOffset.FromUnixTimeSeconds(endDateProp.GetInt64()).UtcDateTime;
}
else if (act.TryGetProperty("end_date_iso", out var endIso) && endIso.ValueKind == JsonValueKind.String)
{
if (DateTime.TryParse(endIso.GetString(), null, System.Globalization.DateTimeStyles.RoundtripKind, out var endDt2))
signal.EndDate = endDt2.ToUniversalTime();
}
string shareType = string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome;
_logger.Trade($"🚨 [QUELLE: {trader.DisplayName}] Neuer Trade erkannt!\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" Aktion: {signal.Side} {shareType} ({signal.Size:F2} Shares @ ${signal.Price:F3})\n" +
$" Zeit: {signal.Timestamp:HH:mm:ss} UTC");
// Push to the processing queue
_signalWriter.TryWrite(signal);
}
catch (Exception ex)
{
_logger.Warning($"Fehler beim Parsen einer Activity JSON: {ex.Message}");
}
}
}
}
-602
View File
@@ -1,602 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
public class TraderMonitorService : BackgroundService
{
private readonly TradingState _state;
private readonly PolymarketApiService _api;
private readonly PolymarketClobClient _clob;
private readonly ChannelWriter<CopySignal> _signalWriter;
private readonly ChannelWriter<ClosedTrade> _closedTradeWriter;
private readonly TerminalLogger _logger;
// Prevents duplicates. Fast O(1) lookup cache to prevent DB spam.
private readonly ConcurrentDictionary<string, DateTime> _processedTxHashes = new();
private DateTime _lastHashCleanup = DateTime.UtcNow;
private readonly ConcurrentDictionary<string, bool> _processedClosures = new();
private readonly ConcurrentDictionary<string, DateTime> _lastPolled = new();
private DateTime _lastLivePoll = DateTime.MinValue;
public TraderMonitorService(
TradingState state,
PolymarketApiService api,
PolymarketClobClient clob,
ChannelWriter<CopySignal> signalWriter,
ChannelWriter<ClosedTrade> closedTradeWriter,
TerminalLogger logger)
{
_state = state;
_api = api;
_clob = clob;
_signalWriter = signalWriter;
_closedTradeWriter = closedTradeWriter;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.Info("TraderMonitorService started background API priority polling...");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await PollActiveTradersAsync(stoppingToken);
// Live Accounts open positions sync (Runs every 30s instead of slamming API constantly)
if ((DateTime.UtcNow - _lastLivePoll).TotalSeconds > 30)
{
await PollLiveAccountsAsync(stoppingToken);
await PollDemoExpirationsAsync(stoppingToken);
await CleanupStaleOpenOrdersAsync(stoppingToken);
_lastLivePoll = DateTime.UtcNow;
}
}
catch (Exception ex)
{
_logger.Error($"TraderMonitor polling error: {ex.Message}");
}
// Global Engine Tick (dynamic queue evaluation)
await Task.Delay(1000, stoppingToken);
}
}
private async Task PollActiveTradersAsync(CancellationToken ct)
{
// Only process ACTIVE trader copies if not paused/inactive
if (_state.GlobalTradingPaused ||
(_state.DemoTradingMode == TradingMode.Inactive && _state.LiveTradingMode == TradingMode.Inactive))
{
return;
}
var activeTraders = _state.Traders.Values.Where(t => t.IsActive).ToList();
if (activeTraders.Count == 0) return;
var now = DateTime.UtcNow;
var toPoll = new List<TrackedTrader>();
bool isWssHealthy = _state.IsAlchemyHealthy;
// Calculate Dynamic Priorities
// Data API rate limit: 1000 req/10s (general).
// Worst case: 30 traders × high prio (3s) = ~100 req/10s = 10% capacity.
// With medium prio at 10s and batches of 10: well within limits.
foreach (var trader in activeTraders)
{
if (!_lastPolled.TryGetValue(trader.WalletAddress, out var lastPoll))
lastPoll = DateTime.MinValue;
double secondsSinceLastPoll = (now - lastPoll).TotalSeconds;
int requiredInterval = 10; // Medium Prio Default (Data API: 1000/10s headroom)
if (isWssHealthy)
{
// If WSS is healthy, fall back to safety-net polling
requiredInterval = 60; // 1 minute (was 2 min)
}
else
{
if (trader.TotalTrades > 20 || trader.Winrate30t >= 60.0)
requiredInterval = 3; // High Prio (unchanged — already fast)
else if (trader.TotalTrades < 5)
requiredInterval = 30; // Low Prio (was 120s)
}
if (secondsSinceLastPoll >= requiredInterval)
{
toPoll.Add(trader);
}
}
if (toPoll.Count == 0) return;
// Batch Execution (Max 10 Concurrent Requests to respect API limits)
int batchSize = 10;
for (int i = 0; i < toPoll.Count; i += batchSize)
{
if (ct.IsCancellationRequested) break;
var batch = toPoll.Skip(i).Take(batchSize);
var tasks = batch.Select(async trader =>
{
_lastPolled[trader.WalletAddress] = DateTime.UtcNow;
System.Diagnostics.Stopwatch? sw = null;
if (_state.DebugPollingLog) sw = System.Diagnostics.Stopwatch.StartNew();
var activity = await _api.GetTraderActivityAsync(trader.WalletAddress, limit: 50);
if (_state.DebugPollingLog && sw != null)
{
sw.Stop();
_logger.Debug($"[API-Profiler] Activity-Request für Trader {trader.DisplayName} dauerte {sw.ElapsedMilliseconds} ms.");
}
foreach (var act in activity)
{
ProcessActivityItem(act, trader);
}
});
await Task.WhenAll(tasks);
await Task.Delay(200, ct); // Tiny 200ms breath between batches
}
// Cleanup old hashes periodically (keep for 24 hours to prevent ANY duplicates)
if ((DateTime.UtcNow - _lastHashCleanup).TotalHours > 1)
{
var cutoff = DateTime.UtcNow.AddHours(-24);
var expired = _processedTxHashes.Where(x => x.Value < cutoff).Select(x => x.Key).ToList();
foreach (var k in expired) _processedTxHashes.TryRemove(k, out _);
_lastHashCleanup = DateTime.UtcNow;
}
}
/// <summary>
/// Triggered instantly by the AlchemyWebsocketService when an EVM TransferSingle is detected.
/// </summary>
public void TriggerManualPoll(string walletAddress)
{
var trader = _state.Traders.Values.FirstOrDefault(t => t.WalletAddress.Equals(walletAddress, StringComparison.OrdinalIgnoreCase));
if (trader != null && trader.IsActive)
{
// Force an immediate poll on the next tick by artificially advancing the last poll date
_lastPolled[trader.WalletAddress] = DateTime.MinValue;
}
}
private async Task PollDemoExpirationsAsync(CancellationToken ct)
{
var demoAccounts = _state.Accounts.Values.Where(a => a.IsDemo && a.IsActive).ToList();
if (demoAccounts.Count == 0) return;
foreach (var acc in demoAccounts)
{
if (ct.IsCancellationRequested) break;
// Check positions that are near expiry, recently expired, or have no expiry but have a slug
var checkPositions = acc.OpenPositions.Values.Where(p =>
!string.IsNullOrEmpty(p.MarketSlug) &&
(
// Has expiry and is within check window (-1 day to +30 days)
(p.ExpiryDate.HasValue &&
(DateTime.UtcNow - p.ExpiryDate.Value).TotalDays > -1 &&
(DateTime.UtcNow - p.ExpiryDate.Value).TotalDays < 30)
||
// No expiry date at all — always check via API
!p.ExpiryDate.HasValue
)).ToList();
foreach (var pos in checkPositions)
{
var (isClosed, isWinner) = await _api.CheckMarketResolutionAsync(pos.MarketSlug, pos.TokenId);
if (isClosed)
{
decimal exitPrice = isWinner ? 1.0m : 0.0m;
_logger.Info($"🏆 Demo Market {pos.MarketQuestion} aufgelöst! Auszahlung: ${(exitPrice * pos.Size):F2}");
var signal = new CopySignal
{
TraderId = 0,
TokenId = pos.TokenId,
MarketSlug = pos.MarketSlug,
MarketQuestion = pos.MarketQuestion,
Outcome = pos.Outcome,
Side = "SELL",
Price = exitPrice,
Size = pos.Size,
Timestamp = DateTime.UtcNow,
Reason = "Market Resolved"
};
_signalWriter.TryWrite(signal);
await Task.Delay(500, ct);
}
}
}
}
private async Task PollLiveAccountsAsync(CancellationToken ct)
{
// Always sync live positions so the Dashboard UI accurately reflects open PnL and portfolio balance
var liveAccounts = _state.Accounts.Values.Where(a => !a.IsDemo && a.IsActive && !string.IsNullOrEmpty(a.WalletAddress)).ToList();
if (liveAccounts.Count == 0) return;
foreach (var acc in liveAccounts)
{
if (ct.IsCancellationRequested) break;
var posList = await _api.SyncOpenPositionsAsync(acc.WalletAddress);
if (posList.Count == 0) continue;
var currentTokens = new HashSet<string>();
foreach (var posJson in posList)
{
string asset = posJson.TryGetProperty("asset", out var ap) ? ap.GetString() ?? "" : "";
if (string.IsNullOrEmpty(asset)) continue;
currentTokens.Add(asset);
string slug = posJson.TryGetProperty("slug", out var sp) ? sp.GetString() ?? "" : "";
string title = posJson.TryGetProperty("title", out var tp) ? tp.GetString() ?? "" : "";
string opp = posJson.TryGetProperty("oppositeOutcome", out var op) ? op.GetString() ?? "" : "No";
decimal size = 0m, entryPrice = 0m, amountUsd = 0m, curPrice = 0m, curValue = 0m;
if (posJson.TryGetProperty("size", out var sprop)) size = ParseDecimal(sprop);
if (posJson.TryGetProperty("avgPrice", out var aprop)) entryPrice = ParseDecimal(aprop);
// Critical Fix: "totalBought" is size. "initialValue" is original USD investment cost.
if (posJson.TryGetProperty("initialValue", out var tbprop)) amountUsd = ParseDecimal(tbprop);
if (posJson.TryGetProperty("curPrice", out var cpprop)) curPrice = ParseDecimal(cpprop);
if (posJson.TryGetProperty("currentValue", out var cvprop)) curValue = ParseDecimal(cvprop);
DateTime? expiry = null;
if (posJson.TryGetProperty("endDate", out var ep))
{
if (DateTime.TryParse(ep.GetString(), out var ed)) expiry = DateTime.SpecifyKind(ed.Date, DateTimeKind.Utc);
}
if (acc.OpenPositions.TryGetValue(asset, out var existing))
{
existing.Size = size;
existing.EntryPrice = entryPrice;
existing.AmountUsd = amountUsd;
existing.CurrentPrice = curPrice;
existing.CurrentValueUsd = curValue;
if (expiry.HasValue) existing.ExpiryDate = expiry;
}
else
{
var newPos = new Position
{
TokenId = asset,
MarketSlug = slug,
MarketQuestion = title,
Outcome = opp == "Yes" ? "No" : "Yes",
SourceTraderName = "Live Sync",
Side = "BUY",
Size = size,
EntryPrice = entryPrice,
AmountUsd = amountUsd,
CurrentPrice = curPrice,
CurrentValueUsd = curValue,
ExpiryDate = expiry
};
acc.OpenPositions.TryAdd(asset, newPos);
_logger.Info($"🌐 Live Position erkannt: {title} ({newPos.Outcome}) - ${amountUsd} - Account: {acc.Name}");
}
}
var tokensToRemove = acc.OpenPositions
.Where(kvp => !currentTokens.Contains(kvp.Key))
.Where(kvp => (DateTime.UtcNow - kvp.Value.OpenedAt).TotalMinutes > 5)
.Select(kvp => kvp.Key)
.ToList();
if (tokensToRemove.Count > 0)
{
var closedPositions = await _api.SyncClosedPositionsAsync(acc.WalletAddress, 50);
foreach (var k in tokensToRemove)
{
if (acc.OpenPositions.TryRemove(k, out var removedPos))
{
JsonElement? matchedClose = null;
foreach (var cm in closedPositions)
{
if (cm.TryGetProperty("asset", out var ap) && ap.GetString() == k)
{
matchedClose = cm;
break;
}
}
if (matchedClose.HasValue)
{
decimal realizedPnl = 0m;
if (matchedClose.Value.TryGetProperty("realizedPnl", out var rPnlProp)) realizedPnl = ParseDecimal(rPnlProp);
_state.GlobalPnl += realizedPnl;
decimal exitPrice = removedPos.Size > 0 ? (removedPos.AmountUsd + realizedPnl) / removedPos.Size : 0m;
string duplicateKey = $"{acc.AccountId}_{removedPos.TokenId}";
if (!_processedClosures.ContainsKey(duplicateKey))
{
_logger.Info($"🏆 Live Market {removedPos.MarketQuestion} geschlossen! PnL: ${(realizedPnl):F2}");
var ctRecord = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = acc.AccountId,
SourceTraderId = removedPos.SourceTraderId,
IsDemo = false,
MarketSlug = removedPos.MarketSlug,
MarketQuestion = removedPos.MarketQuestion,
Outcome = removedPos.Outcome,
Side = "SELL",
EntryPrice = removedPos.EntryPrice,
ExitPrice = exitPrice,
Size = removedPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = removedPos.AmountUsd > 0 ? (realizedPnl / removedPos.AmountUsd * 100m) : 0m,
OpenedAt = removedPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = "API Closed"
};
_processedClosures.TryAdd(duplicateKey, true);
_closedTradeWriter.TryWrite(ctRecord);
}
}
else
{
var (isClosed, isWinner) = await _api.CheckMarketResolutionAsync(removedPos.MarketSlug, removedPos.TokenId);
if (isClosed)
{
decimal exitPrice = isWinner ? 1.0m : 0.0m;
decimal exitUsd = removedPos.Size * exitPrice;
decimal realizedPnl = exitUsd - removedPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
string duplicateKey = $"{acc.AccountId}_{removedPos.TokenId}";
if (!_processedClosures.ContainsKey(duplicateKey))
{
_logger.Info($"🏆 Live Market {removedPos.MarketQuestion} aufgelöst (Fallback)! Auszahlung: ${(exitPrice * removedPos.Size):F2}");
var ctRecord = new ClosedTrade
{
TradeId = _state.TotalCopyTrades,
AccountId = acc.AccountId,
SourceTraderId = removedPos.SourceTraderId,
IsDemo = false,
MarketSlug = removedPos.MarketSlug,
MarketQuestion = removedPos.MarketQuestion,
Outcome = removedPos.Outcome,
Side = "SELL",
EntryPrice = removedPos.EntryPrice,
ExitPrice = exitPrice,
Size = removedPos.Size,
RealizedPnl = realizedPnl,
PnlPercent = removedPos.AmountUsd > 0 ? (realizedPnl / removedPos.AmountUsd * 100m) : 0m,
OpenedAt = removedPos.OpenedAt,
ClosedAt = DateTime.UtcNow,
ExitReason = "API Resolved"
};
_processedClosures.TryAdd(duplicateKey, true);
_closedTradeWriter.TryWrite(ctRecord);
}
if (isWinner)
{
/*
* DEATIVIERT: Automatischer Redeem via Python Script ist vorerst pausiert.
* User kann die gewonnenen Shares per Klick im Polymarket Web-Interface redeemen.
* Die Datenbank hat die PnL trotzdem bereits korrekt aufgezeichnet!
*
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "python",
Arguments = $"redeem_markets.py {removedPos.TokenId} {acc.ApiKey} {acc.PrivateKey} {acc.ApiPassphrase}",
UseShellExecute = false,
CreateNoWindow = true
});
_logger.Info($"Python Redeem Script für Token {removedPos.TokenId} asynchron ausgeführt.");
}
catch (Exception ex)
{
_logger.Error($"Fehler beim Starten von redeem_markets.py: {ex.Message}");
}
*/
_logger.Info($"🏆 Token {removedPos.TokenId} bereit für manuellen Redeem via Polymarket-Webseite. (P&L wurde bereits gebucht).");
}
}
else
{
_logger.Info($"🌐 Live Position {removedPos.MarketQuestion} (Ext. Verkauft/Wartend)");
}
}
}
}
}
await Task.Delay(500, ct);
}
}
private async Task CleanupStaleOpenOrdersAsync(CancellationToken ct)
{
var keysToProcess = _state.PendingOrderTimestamps.ToArray();
if (keysToProcess.Length == 0) return;
foreach (var kvp in keysToProcess)
{
if (ct.IsCancellationRequested) break;
var parts = kvp.Key.Split('_', 2);
if (parts.Length != 2 || !int.TryParse(parts[0], out int accountId)) continue;
string tokenId = parts[1];
if (!_state.Accounts.TryGetValue(accountId, out var account) || account.IsDemo) continue;
// Determine timeout based on trader category
int timeoutMinutes = 30; // Default: 30 min
if (_state.Traders.TryGetValue(kvp.Value.SourceTraderId, out var trader) && trader.Category == "HF")
{
timeoutMinutes = 3; // HF Trader: 3 min
}
double ageMinutes = (DateTime.UtcNow - kvp.Value.PlacedAt).TotalMinutes;
if (ageMinutes < timeoutMinutes) continue;
// Order is stale — cancel it
try
{
var openOrders = await _clob.GetOpenOrdersAsync(account, tokenId);
if (openOrders.Count > 0)
{
foreach (var order in openOrders)
{
_logger.Warning($"⏰ [{account.Name}] Stale Order Timeout ({ageMinutes:F0} min > {timeoutMinutes} min). Storniere Order {order.Id} für {tokenId.Substring(0, Math.Min(10, tokenId.Length))}...");
await _clob.CancelOrderAsync(account, order.Id);
}
}
}
catch (Exception ex)
{
_logger.Error($"Stale Order Cleanup Error: {ex.Message}");
}
// Remove from tracking regardless (even if cancel failed, we don't want to spam retries)
_state.PendingOrderTimestamps.TryRemove(kvp.Key, out _);
}
}
private decimal ParseDecimal(JsonElement prop)
{
if (prop.ValueKind == JsonValueKind.Number) return prop.GetDecimal();
if (prop.ValueKind == JsonValueKind.String && decimal.TryParse(prop.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) return parsed;
return 0m;
}
private void ProcessActivityItem(JsonElement act, TrackedTrader trader)
{
try
{
string txHash = act.GetProperty("transactionHash").GetString() ?? "";
if (string.IsNullOrEmpty(txHash) || _processedTxHashes.ContainsKey(txHash))
return; // Duplicate or invalid
string type = act.GetProperty("type").GetString() ?? "";
if (type.ToUpper() != "TRADE" && type.ToUpper() != "BUY" && type.ToUpper() != "SELL")
return;
string sideStr = type; // Fallback to type
if (act.TryGetProperty("side", out var sideProp) && sideProp.ValueKind == JsonValueKind.String) sideStr = sideProp.GetString() ?? sideStr;
else if (act.TryGetProperty("action", out var actionProp) && actionProp.ValueKind == JsonValueKind.String) sideStr = actionProp.GetString() ?? sideStr;
else if (act.TryGetProperty("tradeType", out var ttProp) && ttProp.ValueKind == JsonValueKind.String) sideStr = ttProp.GetString() ?? sideStr;
string asset = "";
if (act.TryGetProperty("asset", out var assetProp) && assetProp.ValueKind == JsonValueKind.String) asset = assetProp.GetString() ?? "";
if (string.IsNullOrEmpty(asset) && act.TryGetProperty("tokenId", out var tidProp) && tidProp.ValueKind == JsonValueKind.String) asset = tidProp.GetString() ?? "";
if (string.IsNullOrEmpty(asset) && act.TryGetProperty("token_id", out var t_idProp) && t_idProp.ValueKind == JsonValueKind.String) asset = t_idProp.GetString() ?? "";
if (string.IsNullOrEmpty(asset) && act.TryGetProperty("conditionId", out var cidProp) && cidProp.ValueKind == JsonValueKind.String) asset = cidProp.GetString() ?? "";
if (string.IsNullOrEmpty(asset) && act.TryGetProperty("condition_id", out var c_idProp) && c_idProp.ValueKind == JsonValueKind.String) asset = c_idProp.GetString() ?? "";
decimal price = 0m;
if (act.TryGetProperty("price", out var priceProp))
{
if (priceProp.ValueKind == JsonValueKind.Number) price = priceProp.GetDecimal();
else if (priceProp.ValueKind == JsonValueKind.String) decimal.TryParse(priceProp.GetString(), out price);
}
decimal size = 0m;
if (act.TryGetProperty("size", out var sizeProp))
{
if (sizeProp.ValueKind == JsonValueKind.Number) size = sizeProp.GetDecimal();
else if (sizeProp.ValueKind == JsonValueKind.String) decimal.TryParse(sizeProp.GetString(), out size);
}
// Parse timestamp to prevent old trades
DateTime tradeTs = DateTime.UtcNow;
if (act.TryGetProperty("timestamp", out var tsProp))
{
if (tsProp.ValueKind == JsonValueKind.Number) // Unix
tradeTs = DateTimeOffset.FromUnixTimeSeconds(tsProp.GetInt64()).UtcDateTime;
else if (tsProp.ValueKind == JsonValueKind.String && DateTime.TryParse(tsProp.GetString(), out var dt))
tradeTs = dt.ToUniversalTime();
}
// If trade is older than 120 seconds, skip
if ((DateTime.UtcNow - tradeTs).TotalSeconds > 120)
{
// Still add to seen so we don't re-parse it
_processedTxHashes.TryAdd(txHash, DateTime.UtcNow);
return;
}
_processedTxHashes.TryAdd(txHash, DateTime.UtcNow);
var displayQuestion = "";
if (act.TryGetProperty("title", out var titleProp)) displayQuestion = titleProp.GetString() ?? "";
var signal = new CopySignal
{
TraderId = trader.Id,
TokenId = asset,
ConditionId = "",
MarketSlug = act.TryGetProperty("slug", out var sp) ? sp.GetString() ?? "" : (act.TryGetProperty("marketSlug", out var msp) ? msp.GetString() ?? "" : ""),
Side = sideStr.ToUpper().Contains("SELL") ? "SELL" : "BUY",
Price = price,
Size = size,
Timestamp = tradeTs,
MarketQuestion = displayQuestion,
Outcome = act.TryGetProperty("outcome", out var outProp) ? outProp.GetString() ?? "" : "",
Reason = sideStr.ToUpper().Contains("SELL") ? "Master Trader Sold" : ""
};
// Parse endDate from activity JSON for market expiry
if (act.TryGetProperty("endDate", out var endDateProp))
{
if (endDateProp.ValueKind == JsonValueKind.String && DateTime.TryParse(endDateProp.GetString(), null, System.Globalization.DateTimeStyles.RoundtripKind, out var endDt))
signal.EndDate = endDt.ToUniversalTime();
else if (endDateProp.ValueKind == JsonValueKind.Number)
signal.EndDate = DateTimeOffset.FromUnixTimeSeconds(endDateProp.GetInt64()).UtcDateTime;
}
else if (act.TryGetProperty("end_date_iso", out var endIso) && endIso.ValueKind == JsonValueKind.String)
{
if (DateTime.TryParse(endIso.GetString(), null, System.Globalization.DateTimeStyles.RoundtripKind, out var endDt2))
signal.EndDate = endDt2.ToUniversalTime();
}
string shareType = string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome;
_logger.Trade($"🚨 [QUELLE: {trader.DisplayName}] Neuer Trade erkannt!\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" Aktion: {signal.Side} {shareType} ({signal.Size:F2} Shares @ ${signal.Price:F3})\n" +
$" Zeit: {signal.Timestamp:HH:mm:ss} UTC");
// Push to the processing queue
_signalWriter.TryWrite(signal);
}
catch (Exception ex)
{
_logger.Warning($"Fehler beim Parsen einer Activity JSON: {ex.Message}");
}
}
}
}
-15
View File
@@ -1,15 +0,0 @@
using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace PolyTraderSharp.services
{
internal class DatabaseService
{
}
}
-30
View File
@@ -1,30 +0,0 @@
using System;
using MongoDB.Driver;
using PolyTraderSharp.Extensions;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace PolyTraderSharp.services
{
/*
* Hier sollen alle Server bezogenen Einstellungen, die im Settings Tab gesetzt werden in einer XML Datei im Programmordner gespeichert und geladen werden können.
Zusätzlich soll ein Reload das neuladen von geänderten Einstellungen in allen bereichen anstoßen.
*/
internal class Settings
{
public void ReloadSettings()
{
}
public void LoadSettings() {
}
public void SaveSettings() {
}
}
}