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:
@@ -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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user