Baseline: Ausgangszustand vor Modularisierung
Erster Commit des bestehenden monolithischen WinForms-Copytraders, inklusive der Alt-Backups (*.bak), damit diese dauerhaft in der Historie rekonstruierbar bleiben. Threema-Lib unter libs/ wurde vendored (nested .git entfernt). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
using System;
|
||||
using MongoDB.Driver;
|
||||
using PolyTraderSharp.Extensions;
|
||||
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;
|
||||
|
||||
namespace PolyTraderSharp.Services
|
||||
{
|
||||
public class AlchemyWebsocketService : BackgroundService
|
||||
{
|
||||
private const string CtfContractAddress = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045";
|
||||
private const string TransferSingleTopic = "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62";
|
||||
private const string TransferBatchTopic = "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7ce";
|
||||
|
||||
private readonly TradingState _state;
|
||||
private readonly ServerSettings _settings;
|
||||
private readonly TraderMonitorService _traderMonitor;
|
||||
private readonly TerminalLogger _logger;
|
||||
|
||||
public AlchemyWebsocketService(
|
||||
TradingState state,
|
||||
ServerSettings settings,
|
||||
TraderMonitorService traderMonitor,
|
||||
TerminalLogger logger)
|
||||
{
|
||||
_state = state;
|
||||
_settings = settings;
|
||||
_traderMonitor = traderMonitor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!_settings.EnableBlockchainListener || string.IsNullOrEmpty(_settings.PolygonRpcUrl))
|
||||
{
|
||||
_logger.Info("Blockchain Listener is disabled in settings. Using raw polling.");
|
||||
_state.IsAlchemyHealthy = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info("Alchemy WSS Service starting up...");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (_state.GlobalTradingPaused ||
|
||||
(_state.LiveTradingMode == TradingMode.Inactive && _state.DemoTradingMode == TradingMode.Inactive))
|
||||
{
|
||||
_state.IsAlchemyHealthy = false;
|
||||
await Task.Delay(5000, stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await ConnectAndListenAsync(stoppingToken);
|
||||
}
|
||||
catch (WebSocketException ex)
|
||||
{
|
||||
// Usually indicates a connection drop or 429
|
||||
_logger.Warning($"Alchemy WSS drop: {ex.Message}. Falling back to API polling for 5 minutes.");
|
||||
_state.IsAlchemyHealthy = false;
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ex.Message.Contains("429") || ex.Message.Contains("Too Many Requests"))
|
||||
{
|
||||
_logger.Error($"Alchemy HTTP 429 Limit reached. Suspending WSS for 5 minutes.");
|
||||
_state.IsAlchemyHealthy = false;
|
||||
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Error($"Alchemy WSS Error: {ex.Message}. Retrying in 10s...");
|
||||
_state.IsAlchemyHealthy = false;
|
||||
await Task.Delay(10000, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ConnectAndListenAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var ws = new ClientWebSocket();
|
||||
var wssUrl = _settings.PolygonRpcUrl.Replace("https://", "wss://").Replace("http://", "ws://");
|
||||
|
||||
_logger.Info($"Connecting to Alchemy WebSocket: {wssUrl.Substring(0, Math.Min(35, wssUrl.Length))}...");
|
||||
|
||||
await ws.ConnectAsync(new Uri(wssUrl), stoppingToken);
|
||||
_state.IsAlchemyHealthy = true;
|
||||
_logger.Info("✅ Alchemy WSS Connected. Dynamic API-Throttling activated.");
|
||||
|
||||
var activeTraders = _state.Traders.Values.Where(t => t.IsActive).ToList();
|
||||
var activeStateHash = string.Join(",", activeTraders.OrderBy(t => t.Id).Select(t => t.WalletAddress.ToLowerInvariant()));
|
||||
|
||||
if (activeTraders.Count > 0)
|
||||
{
|
||||
var paddedAddresses = activeTraders.Select(t => PadAddress(t.WalletAddress)).ToList();
|
||||
int batchSize = 3; // Alchemy limits Topic arrays to a max of 3/4 entries
|
||||
int reqId = 1;
|
||||
|
||||
for (int i = 0; i < paddedAddresses.Count; i += batchSize)
|
||||
{
|
||||
var chunk = paddedAddresses.Skip(i).Take(batchSize).ToList();
|
||||
var addrJson = JsonSerializer.Serialize(chunk);
|
||||
|
||||
// Buys: Master Trader is the receiver (Topic 3)
|
||||
var subscribeBuysStr = $@"{{
|
||||
""jsonrpc"": ""2.0"",
|
||||
""id"": {reqId++},
|
||||
""method"": ""eth_subscribe"",
|
||||
""params"": [
|
||||
""logs"",
|
||||
{{
|
||||
""address"": ""{CtfContractAddress}"",
|
||||
""topics"": [
|
||||
[""{TransferSingleTopic}"", ""{TransferBatchTopic}""],
|
||||
null,
|
||||
null,
|
||||
{addrJson}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}}";
|
||||
|
||||
// Sells: Master Trader is the sender (Topic 2)
|
||||
var subscribeSellsStr = $@"{{
|
||||
""jsonrpc"": ""2.0"",
|
||||
""id"": {reqId++},
|
||||
""method"": ""eth_subscribe"",
|
||||
""params"": [
|
||||
""logs"",
|
||||
{{
|
||||
""address"": ""{CtfContractAddress}"",
|
||||
""topics"": [
|
||||
[""{TransferSingleTopic}"", ""{TransferBatchTopic}""],
|
||||
null,
|
||||
{addrJson}
|
||||
]
|
||||
}}
|
||||
]
|
||||
}}";
|
||||
|
||||
await ws.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes(subscribeBuysStr)), WebSocketMessageType.Text, true, stoppingToken);
|
||||
await ws.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes(subscribeSellsStr)), WebSocketMessageType.Text, true, stoppingToken);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("Keine aktiven Master-Trader. Socket läuft im Standby...");
|
||||
}
|
||||
|
||||
var buffer = new byte[1024 * 64];
|
||||
|
||||
var loopCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
|
||||
var monitorTask = Task.Run(async () =>
|
||||
{
|
||||
while (!loopCts.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(5000, loopCts.Token);
|
||||
var currentTraders = _state.Traders.Values.Where(t => t.IsActive).ToList();
|
||||
var currentHash = string.Join(",", currentTraders.OrderBy(t => t.Id).Select(t => t.WalletAddress.ToLowerInvariant()));
|
||||
if (currentHash != activeStateHash)
|
||||
{
|
||||
_logger.Info("🔄 Master-Trader Liste hat sich geändert. Starte Alchemy WSS mit neuen Filtern neu...");
|
||||
loopCts.Cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
while (ws.State == WebSocketState.Open && !loopCts.IsCancellationRequested)
|
||||
{
|
||||
var result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), loopCts.Token);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
||||
|
||||
try
|
||||
{
|
||||
ProcessMessage(message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error parsing WSS msg: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected when reconnecting due to trader list change
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!loopCts.IsCancellationRequested) loopCts.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessMessage(string jsonStr)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(jsonStr);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("params", out var paramsEl)) return;
|
||||
if (!paramsEl.TryGetProperty("result", out var resultEl)) return;
|
||||
if (!resultEl.TryGetProperty("topics", out var topicsEl) || topicsEl.GetArrayLength() < 4) return;
|
||||
|
||||
var topics = topicsEl.EnumerateArray().Select(t => t.GetString()).ToList();
|
||||
var fromTopic = topics[2]?.ToLowerInvariant();
|
||||
var toTopic = topics[3]?.ToLowerInvariant();
|
||||
|
||||
if (fromTopic == null || toTopic == null) return;
|
||||
|
||||
var activeTraders = _state.Traders.Values.Where(t => t.IsActive).ToList();
|
||||
string? triggeredAddress = null;
|
||||
|
||||
foreach (var trader in activeTraders)
|
||||
{
|
||||
var padded = PadAddress(trader.WalletAddress);
|
||||
if (fromTopic == padded || toTopic == padded)
|
||||
{
|
||||
triggeredAddress = trader.WalletAddress;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(triggeredAddress))
|
||||
{
|
||||
string txHash = resultEl.TryGetProperty("transactionHash", out var th) ? th.GetString() ?? "unknown" : "unknown";
|
||||
_traderMonitor.TriggerFastBlockchainPoll(txHash, _settings.PolygonRpcUrl, triggeredAddress);
|
||||
}
|
||||
}
|
||||
|
||||
private string PadAddress(string address)
|
||||
{
|
||||
string stripped = address.Replace("0x", "", StringComparison.OrdinalIgnoreCase).ToLowerInvariant();
|
||||
return "0x" + stripped.PadLeft(64, '0');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
using System;
|
||||
using MongoDB.Driver;
|
||||
using PolyTraderSharp.Extensions;
|
||||
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 IMongoDatabase? _db;
|
||||
private readonly ConcurrentDictionary<int, SemaphoreSlim> _accountSemaphores = new();
|
||||
private readonly ConcurrentDictionary<int, DateTime> _lastInactiveLogPerTrader = new();
|
||||
|
||||
public CopyTradingEngine(
|
||||
TradingState state,
|
||||
ChannelReader<CopySignal> signalReader,
|
||||
ChannelWriter<ClosedTrade> closedTradeWriter,
|
||||
TerminalLogger logger,
|
||||
PolymarketClobClient clob,
|
||||
PolymarketApiService api,
|
||||
IMongoDatabase? db = null)
|
||||
{
|
||||
_state = state;
|
||||
_signalReader = signalReader;
|
||||
_closedTradeWriter = closedTradeWriter;
|
||||
_logger = logger;
|
||||
_clob = clob;
|
||||
_api = api;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public override async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.Info("Starte Preload des MarketCache aus MongoDB um Flaschenhälse zu vermeiden...");
|
||||
if (_db != null)
|
||||
{
|
||||
var coll = _db.GetCollection<MarketData>("markets");
|
||||
|
||||
// Initialize cache for EVERYTHING in DB that is not closed!
|
||||
var activeMarkets = coll.LiteFind(x => !x.Closed);
|
||||
int loaded = 0;
|
||||
|
||||
foreach (var md in activeMarkets)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(md.ClobTokenIds))
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenIds = System.Text.Json.JsonSerializer.Deserialize<List<string>>(md.ClobTokenIds);
|
||||
if (tokenIds != null)
|
||||
{
|
||||
foreach (var token in tokenIds)
|
||||
{
|
||||
_state.MarketCache[token] = md;
|
||||
loaded++;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { } // Ignore malformed JSON cleanly
|
||||
}
|
||||
}
|
||||
_logger.Info($"MarketCache Preload abgeschlossen: {loaded} Token herangeführt.");
|
||||
}
|
||||
await base.StartAsync(cancellationToken);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
_logger.TradeReasoning($"⏸️ Trade {signal.Side} [{signal.MarketQuestion}] ignoriert: GlobalTradingPaused ist aktiv.");
|
||||
return;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// GLOBAL EXPENSIVE DB/API MARKET CACHE HYDRATION
|
||||
// Runs once per signal, before any account locks!
|
||||
// ==========================================
|
||||
bool isNegRisk = false;
|
||||
if (!string.IsNullOrEmpty(signal.TokenId))
|
||||
{
|
||||
if (_state.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.LiteFind(x => x.ClobTokenIds != null && x.ClobTokenIds.Contains(signal.TokenId)).FirstOrDefault();
|
||||
|
||||
if (marketData == null)
|
||||
{
|
||||
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
|
||||
_state.MarketCache[signal.TokenId] = marketData;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Fehler beim Abrufen von MarketData f\u00fcr Token {signal.TokenId}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// ==========================================
|
||||
|
||||
// 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, isNegRisk));
|
||||
}
|
||||
}
|
||||
await Task.WhenAll(sysaccountTasks);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_state.Traders.TryGetValue(signal.TraderId, out var trader) || !trader.IsActive)
|
||||
{
|
||||
_logger.TradeReasoning($"\u23f8\ufe0f Trade {signal.Side} [{signal.MarketQuestion}] ignoriert: Trader (ID={signal.TraderId}) nicht gefunden oder inaktiv.");
|
||||
return;
|
||||
}
|
||||
|
||||
var accountTasks = new List<Task>();
|
||||
|
||||
foreach (var accountId in trader.AssignedAccountIds)
|
||||
{
|
||||
if (!_state.Accounts.TryGetValue(accountId, out var account) || !account.IsActive)
|
||||
{
|
||||
_logger.TradeReasoning($"\u23f8\ufe0f Trade {signal.Side} [{signal.MarketQuestion}] ignoriert: Account (ID={accountId}) nicht gefunden oder inaktiv.");
|
||||
continue;
|
||||
}
|
||||
|
||||
accountTasks.Add(ProcessAccountOrderAsync(account, trader, signal, isNegRisk));
|
||||
}
|
||||
|
||||
await Task.WhenAll(accountTasks);
|
||||
}
|
||||
|
||||
private async Task ProcessAccountOrderAsync(AccountState account, TrackedTrader? trader, CopySignal signal, bool isNegRisk)
|
||||
{
|
||||
var mode = account.IsDemo ? _state.DemoTradingMode : _state.LiveTradingMode;
|
||||
if (mode == TradingMode.Inactive)
|
||||
{
|
||||
// Rate-limited log: max 1 per trader per 60s to prevent HF spam
|
||||
var traderId = signal.TraderId;
|
||||
var now = DateTime.UtcNow;
|
||||
if (!_lastInactiveLogPerTrader.TryGetValue(traderId, out var lastLog) || (now - lastLog).TotalSeconds >= 60)
|
||||
{
|
||||
_lastInactiveLogPerTrader[traderId] = now;
|
||||
string modeLabel = account.IsDemo ? "Demo" : "Live";
|
||||
_logger.TradeReasoning($"⏸️ Trade {signal.Side} [{signal.MarketQuestion}] [{(string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome)}] ignoriert:\n" +
|
||||
$" Konto: {account.Name}\n" +
|
||||
$" Begründung: {modeLabel}-Trading Modus ist 'Inactive'. Weitere Trades dieses Traders werden für 60s nicht geloggt.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Restrict BUY operations if mode is SellOnly
|
||||
if (mode == TradingMode.SellOnly && signal.Side == "BUY")
|
||||
{
|
||||
_logger.TradeReasoning($"⏸️ Trade BUY [{signal.MarketQuestion}] [{(string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome)}] ignoriert:\n" +
|
||||
$" Konto: {account.Name}\n" +
|
||||
$" Begründung: Trading Modus ist 'SellOnly' — BUY-Trades werden nicht kopiert.");
|
||||
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))
|
||||
{
|
||||
if (account.HasOpenLimitOrders)
|
||||
{
|
||||
await _clob.CancelConflictingOrdersAsync(account, signal.TokenId, signal.Price, signal.Side);
|
||||
}
|
||||
}
|
||||
|
||||
if (_state.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)
|
||||
{
|
||||
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] verworfen (Risk Limit):\n" +
|
||||
$" Konto: {account.Name}\n" +
|
||||
$" Begründung: Preis (${signal.Price:F3}) übersteigt das MaxBuy Limit (${account.MaxBuyPrice:F3})");
|
||||
return;
|
||||
}
|
||||
|
||||
var activePositions = account.OpenPositions.Values.Where(p => IsPositionMarketActive(p)).ToList();
|
||||
decimal investedInMarket = activePositions.FirstOrDefault(p => p.TokenId == signal.TokenId)?.AmountUsd ?? 0m;
|
||||
|
||||
decimal minTrade = 1.0m;
|
||||
decimal maxAllowed = account.TotalBalance * (account.PerMarketLimit / 100.0m);
|
||||
|
||||
// Low Balance Bypass (Stufen-System) ALWAYS APPLIES
|
||||
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;
|
||||
if (trader != null && trader.Category == "HF")
|
||||
{
|
||||
desiredLimitForSix = signal.Price + 0.005m;
|
||||
}
|
||||
else
|
||||
{
|
||||
desiredLimitForSix = signal.Price * (1.0m + account.MaxPriceDifference / 100.0m);
|
||||
}
|
||||
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 ? activePositions.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 = activePositions;
|
||||
|
||||
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;
|
||||
if (trader != null && trader.Category == "HF")
|
||||
{
|
||||
// HF Trader: festes 0.5 Cent (0.005) Limit
|
||||
desiredLimit = signal.Price + 0.005m;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Normaler Trader: prozentuales Limit aus Slave-Account Settings
|
||||
desiredLimit = signal.Price * (1.0m + account.MaxPriceDifference / 100.0m);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ===== MICRO-ORDER FILTER: Polymarket Minimum Size Enforcement =====
|
||||
// Polymarket lehnt Orders mit < 5 Shares ab ("Size lower than the minimum: 5").
|
||||
// Statt die API zu belasten und Fehler-Logs zu erzeugen, filtern wir hier sofort.
|
||||
if (exact.shares < 5.5m || exact.usdc < 0.10m)
|
||||
{
|
||||
_logger.TradeReasoning($"❌ Trade BUY [{signal.MarketQuestion}] [{shareType}] gestoppt:\n" +
|
||||
$" Konto: {account.Name}\n" +
|
||||
$" Begründung: Unter Polymarket Minimum ({exact.shares:F1} Shares / ${exact.usdc:F2} USDC). Min: 5.5 Shares / $0.10.");
|
||||
return;
|
||||
}
|
||||
|
||||
exactShares = exact.shares;
|
||||
exactUsdc = exact.usdc;
|
||||
}
|
||||
else if (signal.Side == "SELL")
|
||||
{
|
||||
// PRE-FLIGHT SELL Check: Exists in portfolio AND opened by the SAME master trader?
|
||||
// CRITICAL: We must NOT sell a position opened by Trader A based on a SELL signal from Trader B.
|
||||
var inPortfolio = account.OpenPositions.Values.FirstOrDefault(p =>
|
||||
(p.TokenId == signal.TokenId || (p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome))
|
||||
&& p.SourceTraderId == signal.TraderId);
|
||||
if (inPortfolio == null)
|
||||
{
|
||||
// Check if position exists but belongs to a different trader (for clearer logging)
|
||||
var wrongTraderPos = account.OpenPositions.Values.FirstOrDefault(p =>
|
||||
p.TokenId == signal.TokenId || (p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome));
|
||||
if (wrongTraderPos != null)
|
||||
{
|
||||
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
|
||||
$" Konto: {account.Name}\n" +
|
||||
$" Begründung: Position gehört Trader '{wrongTraderPos.SourceTraderName}' (ID {wrongTraderPos.SourceTraderId}), SELL kam aber von Trader ID {signal.TraderId}.");
|
||||
}
|
||||
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).");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// PROPORTIONALITY CHECK: Only copy SELL if the master trader is exiting a significant portion (≥30%) of their position.
|
||||
// Active day-traders like SwissTony buy 500 shares then sell 2 (0.4%) — we should NOT copy that.
|
||||
// But if they sell 200 of 500 (40%), that's a real exit signal we must copy.
|
||||
string masterPosKey = $"{signal.TraderId}_{inPortfolio.TokenId}";
|
||||
if (_state.MasterTraderPositions.TryGetValue(masterPosKey, out var masterPos))
|
||||
{
|
||||
decimal masterShares = masterPos.Shares;
|
||||
if (masterShares > 0 && signal.Size > 0)
|
||||
{
|
||||
// Calculate what percentage of the master's known position this SELL represents
|
||||
decimal sellRatio = signal.Size / (masterShares + signal.Size); // +signal.Size because the position was already reduced
|
||||
if (sellRatio < 0.30m)
|
||||
{
|
||||
_logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
|
||||
$" Konto: {account.Name}\n" +
|
||||
$" Begründung: Teilverkauf ({sellRatio:P0} des Bestands). Master hält noch {masterShares:F1} Shares. Signal nur {signal.Size:F1} Shares. Schwelle: 30%.");
|
||||
return;
|
||||
}
|
||||
_logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] FREIGEGEBEN:\n" +
|
||||
$" Konto: {account.Name}\n" +
|
||||
$" Begründung: Signifikanter Verkauf ({sellRatio:P0} des Bestands). Master hatte {masterShares + signal.Size:F1} Shares, verkauft {signal.Size:F1}.");
|
||||
}
|
||||
else if (masterShares <= 0)
|
||||
{
|
||||
// Master has 0 shares according to our tracking, but a SELL signal came in.
|
||||
// This is an inconsistency — either our tracking is stale, or it's a phantom signal.
|
||||
// Do NOT sell blindly. Wait for the next background sync to update the real position.
|
||||
_logger.TradeReasoning($"📊 Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
|
||||
$" Konto: {account.Name}\n" +
|
||||
$" Begründung: Master hält laut Tracking 0 Shares, aber SELL Signal mit {signal.Size:F1} Shares erhalten. Inkonsistenz — ignoriert.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No tracking data yet — apply soft grace period (2 min) as fallback until first sync completes
|
||||
double holdingMinutes = (DateTime.UtcNow - inPortfolio.OpenedAt).TotalMinutes;
|
||||
if (holdingMinutes < 2.0)
|
||||
{
|
||||
_logger.TradeReasoning($"⏳ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
|
||||
$" Konto: {account.Name}\n" +
|
||||
$" Begründung: Kein Master-Position-Tracking verfügbar und Haltezeit erst {holdingMinutes:F1} Min. Warte auf ersten Sync.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Market Metadata loaded globally. Ready for execution.
|
||||
// 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.GetNextTradeId();
|
||||
|
||||
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})");
|
||||
|
||||
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, exactUsdc, orderPrice, "GTD", _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.GetNextTradeId();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Track order placement time for stale order cleanup
|
||||
string orderKey = $"{account.AccountId}_{signal.TokenId}";
|
||||
_state.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow, signal.TraderId);
|
||||
|
||||
// Initialize master position tracking with signal size if not yet tracked
|
||||
// The background sync will update with the real value within 30 seconds
|
||||
string masterKey = $"{signal.TraderId}_{signal.TokenId}";
|
||||
_state.MasterTraderPositions.TryAdd(masterKey, (signal.Size, DateTime.UtcNow));
|
||||
}
|
||||
}
|
||||
}
|
||||
// If SELL -> Divest
|
||||
else if (signal.Side == "SELL")
|
||||
{
|
||||
string orderKey = $"{account.AccountId}_{signal.TokenId}";
|
||||
if (_state.PendingOrderTimestamps.TryGetValue(orderKey, out var pendingInfo))
|
||||
{
|
||||
if ((DateTime.UtcNow - pendingInfo.PlacedAt).TotalSeconds < 20)
|
||||
{
|
||||
return; // Spam-Blockade: Die Order wurde in den letzten 20 Sekunden bereits versendet
|
||||
}
|
||||
}
|
||||
|
||||
bool removed = account.OpenPositions.TryRemove(signal.TokenId, out var openPos);
|
||||
|
||||
// Defense-in-depth: Verify the removed position actually belongs to this trader
|
||||
if (removed && openPos != null && openPos.SourceTraderId != signal.TraderId)
|
||||
{
|
||||
// Wrong trader! Put the position back and treat as not found.
|
||||
account.OpenPositions.TryAdd(signal.TokenId, openPos);
|
||||
removed = false;
|
||||
openPos = null;
|
||||
_logger.Info($"❌ Trade SELL [{signal.MarketQuestion}] [{shareType}] ignoriert:\n" +
|
||||
$" Konto: {account.Name}\n" +
|
||||
$" Begründung: Position gehört einem anderen Trader (Safety Check).");
|
||||
}
|
||||
|
||||
if (!removed && !string.IsNullOrEmpty(signal.MarketSlug))
|
||||
{
|
||||
// Fallback matching must ALSO respect SourceTraderId!
|
||||
var altPos = account.OpenPositions.Values.FirstOrDefault(p =>
|
||||
p.MarketSlug == signal.MarketSlug && p.Outcome == signal.Outcome && p.SourceTraderId == signal.TraderId);
|
||||
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.GetNextTradeId(),
|
||||
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; // Market Order Fallback Limit (PolyMarket Safety)
|
||||
decimal expectedUsdc = openPos.Size * sellLimit;
|
||||
|
||||
var exact = PolymarketClobClient.CalculateExactOrderAmounts(expectedUsdc, 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).");
|
||||
// We don't return to OpenPositions to let dust drop gracefully
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.Info($"🌐 [LIVE-EXECUTION] Sende MARKET SELL an Polymarket CTF-Router...\n" +
|
||||
$" Account: {account.Name}\n" +
|
||||
$" Order: MARKET (Target: {signal.Price:F3})");
|
||||
|
||||
var result = await _clob.PlaceOrderAsync(account, signal.TokenId, signal.Side, expectedUsdc, sellLimit, "MARKET", _state.DebugOrderPayloadLog, isNegRisk);
|
||||
|
||||
if (result == "OK")
|
||||
{
|
||||
// Track order placement time for stale order cleanup / sync routines
|
||||
_state.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow, signal.TraderId);
|
||||
|
||||
_logger.Trade($"✅ [LIVE MARKET SELL PLATZIERT] - {account.Name} - MARKET Swept. 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.");
|
||||
|
||||
// Temporären Cooldown (5 Sek) setzen, um Log-Spam durch wiederholte API-Fehler zu vermeiden
|
||||
_state.PendingOrderTimestamps[orderKey] = (DateTime.UtcNow.AddSeconds(-15), signal.TraderId);
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsPositionMarketActive(Position pos)
|
||||
{
|
||||
// O(1) RAM Lookup. Eliminated LiteDB queries for ultra-low latency.
|
||||
if (_state.MarketCache.TryGetValue(pos.TokenId, out var md))
|
||||
{
|
||||
return !md.Closed;
|
||||
}
|
||||
|
||||
// Defaults to active until cache hydrates.
|
||||
// Better to assume active and restrict budget than auto-open budget on unknown markets.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.ComponentModel;
|
||||
using PolyTraderSharp.Models;
|
||||
|
||||
namespace PolyTraderSharp.Services
|
||||
{
|
||||
public class JobManager
|
||||
{
|
||||
public BindingList<JobStatusRow> Jobs { get; } = new BindingList<JobStatusRow>();
|
||||
|
||||
public void RegisterJob(JobStatusRow job)
|
||||
{
|
||||
Jobs.Add(job);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Driver;
|
||||
using PolyTraderSharp.Extensions;
|
||||
using PolyTraderSharp.Models;
|
||||
using PolyTraderSharp.Services;
|
||||
|
||||
namespace PolyTraderSharp.Services
|
||||
{
|
||||
public class MarketSyncService : BackgroundService
|
||||
{
|
||||
private readonly PolymarketApiService _apiService;
|
||||
private readonly IMongoDatabase _db;
|
||||
private readonly TerminalLogger _logger;
|
||||
private readonly JobStatusRow _jobStatus;
|
||||
private readonly TradingState _state;
|
||||
|
||||
public MarketSyncService(PolymarketApiService apiService, IMongoDatabase db, TerminalLogger logger, JobManager jobManager, TradingState state)
|
||||
{
|
||||
_apiService = apiService;
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
_state = state;
|
||||
|
||||
_jobStatus = new JobStatusRow
|
||||
{
|
||||
JobName = "Market Data Sync",
|
||||
Description = "Polls Polymarket Gamma API for the 1000 newest markets.",
|
||||
StatusText = "Pending Initial Delay..."
|
||||
};
|
||||
|
||||
_jobStatus.ManualTriggerAction = async () =>
|
||||
{
|
||||
string oldStatus = _jobStatus.StatusText;
|
||||
_jobStatus.StatusText = "Running (Manual)...";
|
||||
await SyncMarketsAsync();
|
||||
_jobStatus.StatusText = "Idle";
|
||||
};
|
||||
|
||||
jobManager.RegisterJob(_jobStatus);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.Info("MarketSyncService started. Will sync markets every 1 hour.");
|
||||
|
||||
// Give the app some time to start up before initial sync
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
_jobStatus.StatusText = "Idle";
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (_jobStatus.IsEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
_jobStatus.StatusText = "Running (Scheduled)...";
|
||||
await SyncMarketsAsync();
|
||||
_jobStatus.LastRun = DateTime.Now;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"MarketSyncService loop error: {ex.Message}");
|
||||
_jobStatus.StatusText = "Error!";
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_jobStatus.StatusText != "Error!")
|
||||
_jobStatus.StatusText = "Idle";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_jobStatus.StatusText = "Paused";
|
||||
}
|
||||
|
||||
// Sleep for 3 minutes to keep the Cache extremely fresh against high-frequency listings
|
||||
_jobStatus.NextRun = DateTime.Now.AddMinutes(3);
|
||||
await Task.Delay(TimeSpan.FromMinutes(3), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SyncMarketsAsync()
|
||||
{
|
||||
_logger.Info("Syncing newest markets from Polymarket API...");
|
||||
var newMarkets = await _apiService.GetRecentMarketsAsync(1000);
|
||||
|
||||
if (newMarkets.Count == 0)
|
||||
{
|
||||
_logger.Warning("No markets returned from Polymarket API during sync.");
|
||||
return;
|
||||
}
|
||||
|
||||
var col = _db.GetCollection<MarketData>("markets");
|
||||
col.EnsureIndex(x => x.Id);
|
||||
|
||||
int inserted = 0;
|
||||
int updated = 0;
|
||||
|
||||
foreach (var market in newMarkets)
|
||||
{
|
||||
var existing = col.LiteFindOne(x => x.Id == market.Id);
|
||||
if (existing == null)
|
||||
{
|
||||
col.Insert(market);
|
||||
inserted++;
|
||||
|
||||
// NEW: Hot-Load active markets directly into RAM Cache
|
||||
if (!market.Closed && !string.IsNullOrEmpty(market.ClobTokenIds))
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenIds = System.Text.Json.JsonSerializer.Deserialize<List<string>>(market.ClobTokenIds);
|
||||
if (tokenIds != null)
|
||||
{
|
||||
foreach(var t in tokenIds)
|
||||
{
|
||||
_state.MarketCache[t] = market;
|
||||
}
|
||||
}
|
||||
} catch { } // Ignore JSON parse error if malformed
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update dynamic fields like EndDate, Active, Closed
|
||||
existing.EndDate = market.EndDate;
|
||||
existing.Active = market.Active;
|
||||
existing.Closed = market.Closed;
|
||||
existing.NegRisk = market.NegRisk;
|
||||
|
||||
// The API can sometimes be slow to assign ClobTokenIds. Update them if we got new ones.
|
||||
if (string.IsNullOrEmpty(existing.ClobTokenIds) && !string.IsNullOrEmpty(market.ClobTokenIds))
|
||||
{
|
||||
existing.ClobTokenIds = market.ClobTokenIds;
|
||||
}
|
||||
|
||||
col.Update(existing);
|
||||
updated++;
|
||||
|
||||
// Keep RAM cache synchronized to prevent using stale active/closed flags
|
||||
if (!string.IsNullOrEmpty(existing.ClobTokenIds))
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenIds = System.Text.Json.JsonSerializer.Deserialize<List<string>>(existing.ClobTokenIds);
|
||||
if (tokenIds != null)
|
||||
{
|
||||
foreach(var t in tokenIds)
|
||||
{
|
||||
if (!existing.Closed)
|
||||
{
|
||||
// Unconditionally keep active markets hot in the cache
|
||||
_state.MarketCache[t] = existing;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Only update if it is already there (e.g. to flag it as closed for running logic)
|
||||
if (_state.MarketCache.ContainsKey(t))
|
||||
_state.MarketCache[t] = existing;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { } // Ignore JSON parse error if malformed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Info($"Market Sync Complete: {inserted} new markets, {updated} updated.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using System;
|
||||
using MongoDB.Driver;
|
||||
using PolyTraderSharp.Extensions;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using PolyTraderSharp.Models;
|
||||
|
||||
namespace PolyTraderSharp.Services
|
||||
{
|
||||
public class MasterTraderAnalyticsJob : BackgroundService
|
||||
{
|
||||
private readonly TradingState _state;
|
||||
private readonly TerminalLogger _logger;
|
||||
private readonly IMongoDatabase _db;
|
||||
private readonly JobStatusRow _jobStatus;
|
||||
private readonly PolymarketApiService _api;
|
||||
|
||||
public MasterTraderAnalyticsJob(TradingState state, TerminalLogger logger, IMongoDatabase db, JobManager jobManager, PolymarketApiService api)
|
||||
{
|
||||
_state = state;
|
||||
_logger = logger;
|
||||
_db = db;
|
||||
_api = api;
|
||||
|
||||
_jobStatus = new JobStatusRow
|
||||
{
|
||||
JobName = "MasterTrader History",
|
||||
Description = "Überwacht die Performance aller Master-Trader (P&L, Winrate 7D).",
|
||||
StatusText = "Pending Initial Delay..."
|
||||
};
|
||||
|
||||
_jobStatus.ManualTriggerAction = async () =>
|
||||
{
|
||||
_jobStatus.StatusText = "Running (Manual)...";
|
||||
await RunHistoryAnalyticsAsync();
|
||||
_jobStatus.StatusText = "Idle";
|
||||
_jobStatus.LastRun = DateTime.Now;
|
||||
};
|
||||
|
||||
jobManager.RegisterJob(_jobStatus);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(20), stoppingToken); // Start after other jobs
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (_jobStatus.IsEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
_jobStatus.StatusText = "Running (Scheduled)...";
|
||||
await RunHistoryAnalyticsAsync();
|
||||
_jobStatus.LastRun = DateTime.Now;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error in MasterTraderAnalyticsJob: {ex.Message}");
|
||||
_jobStatus.StatusText = "Error!";
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_jobStatus.StatusText != "Error!") _jobStatus.StatusText = "Idle";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_jobStatus.StatusText = "Paused";
|
||||
}
|
||||
|
||||
// Run twice a day (every 12 hours)
|
||||
_jobStatus.NextRun = DateTime.Now.AddHours(12);
|
||||
await Task.Delay(TimeSpan.FromHours(12), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RunHistoryAnalyticsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info("🔄 Starte Master-Trader Historien-Download und Performance-Analyse...");
|
||||
|
||||
var historyColl = _db.GetCollection<MasterTraderHistoryRecord>("mt_history");
|
||||
historyColl.EnsureIndex(x => x.TraderId);
|
||||
historyColl.EnsureIndex(x => x.ClosedAt);
|
||||
|
||||
DateTime cutoff7Days = DateTime.UtcNow.AddDays(-7);
|
||||
var tradersToAnalyze = _state.Traders.Values.Where(t => t.IsActive && !string.IsNullOrEmpty(t.WalletAddress)).ToList();
|
||||
|
||||
foreach (var trader in tradersToAnalyze)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. Fetch History from Data API (100 is usually enough for 7 days)
|
||||
var closedPositions = await _api.SyncClosedPositionsAsync(trader.WalletAddress, 200);
|
||||
if (closedPositions.Count == 0)
|
||||
{
|
||||
continue; // Might be deleted or no history
|
||||
}
|
||||
|
||||
int inserted = 0;
|
||||
foreach (var cp in closedPositions)
|
||||
{
|
||||
// parse timestamp
|
||||
DateTime closedTs = DateTime.UnixEpoch;
|
||||
if (cp.TryGetProperty("timestamp", out var tsProp))
|
||||
{
|
||||
if (tsProp.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
long tsRaw = tsProp.GetInt64();
|
||||
// if it's 13 digits (ms) vs 10 digits (s)
|
||||
if (tsRaw > 1000000000000) closedTs = DateTimeOffset.FromUnixTimeMilliseconds(tsRaw).UtcDateTime;
|
||||
else closedTs = DateTimeOffset.FromUnixTimeSeconds(tsRaw).UtcDateTime;
|
||||
}
|
||||
else if (tsProp.ValueKind == JsonValueKind.String && long.TryParse(tsProp.GetString(), out long tsStrRaw))
|
||||
{
|
||||
if (tsStrRaw > 1000000000000) closedTs = DateTimeOffset.FromUnixTimeMilliseconds(tsStrRaw).UtcDateTime;
|
||||
else closedTs = DateTimeOffset.FromUnixTimeSeconds(tsStrRaw).UtcDateTime;
|
||||
}
|
||||
}
|
||||
|
||||
// If trade is older than 14 days, ignore parsing to save DB space
|
||||
if (closedTs < DateTime.UtcNow.AddDays(-14)) continue;
|
||||
|
||||
string tokenId = cp.TryGetProperty("asset", out var aProp) ? aProp.GetString() ?? "" : "";
|
||||
|
||||
decimal pnl = 0m;
|
||||
if (cp.TryGetProperty("realizedPnl", out var pProp))
|
||||
{
|
||||
if (pProp.ValueKind == JsonValueKind.Number) pnl = pProp.GetDecimal();
|
||||
else if (pProp.ValueKind == JsonValueKind.String && decimal.TryParse(pProp.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal nPnl))
|
||||
{
|
||||
pnl = nPnl;
|
||||
}
|
||||
}
|
||||
|
||||
// We can approximate uniqueness with TokenId & exact Time (+- 2 seconds)
|
||||
DateTime windowStart = closedTs.AddSeconds(-2);
|
||||
DateTime windowEnd = closedTs.AddSeconds(2);
|
||||
|
||||
bool exists = historyColl.LiteFindOne(x => x.TraderId == trader.Id && x.TokenId == tokenId && x.ClosedAt >= windowStart && x.ClosedAt <= windowEnd) != null;
|
||||
if (!exists)
|
||||
{
|
||||
var record = new MasterTraderHistoryRecord
|
||||
{
|
||||
TraderId = trader.Id,
|
||||
TokenId = tokenId,
|
||||
ClosedAt = closedTs,
|
||||
RealizedPnl = pnl
|
||||
};
|
||||
historyColl.Insert(record);
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep to respect 10/s limits or general rate limits
|
||||
await Task.Delay(200);
|
||||
|
||||
// 2. Calculate Stats from DB
|
||||
var last7DaysTrades = historyColl.LiteFind(x => x.TraderId == trader.Id && x.ClosedAt >= cutoff7Days).ToList();
|
||||
|
||||
trader.TotalTrades = last7DaysTrades.Count;
|
||||
trader.TotalPnl = (double)last7DaysTrades.Sum(x => x.RealizedPnl);
|
||||
|
||||
// Treat positive PnL as win
|
||||
trader.WinningTrades = last7DaysTrades.Count(x => x.RealizedPnl > 0);
|
||||
trader.Winrate30t = trader.TotalTrades > 0 ? Math.Round(((double)trader.WinningTrades / trader.TotalTrades) * 100, 2) : 0;
|
||||
|
||||
// Save updated trader to DB so UI updates
|
||||
var tColl = _db.GetCollection<TrackedTrader>("tracked_traders");
|
||||
tColl.Update(trader);
|
||||
|
||||
if (inserted > 0 && trader.TotalTrades > 0)
|
||||
{
|
||||
_logger.Info($"📊 [MasterTrader: {trader.DisplayName}] - {inserted} neue Trades geladen. 7D: {trader.TotalTrades} Trades | PnL: ${trader.TotalPnl:F2} | Winrate: {trader.Winrate30t}%");
|
||||
}
|
||||
}
|
||||
catch (Exception exInner)
|
||||
{
|
||||
_logger.Error($"Error processing history for MasterTrader {trader.DisplayName}: {exInner}");
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Info("✅ Master-Trader Historien-Analyse abgeschlossen.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"MasterTraderAnalyticsJob Exception: {ex}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using MongoDB.Driver;
|
||||
using PolyTraderSharp.Extensions;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using PolyTraderSharp.Models;
|
||||
|
||||
namespace PolyTraderSharp.Services
|
||||
{
|
||||
public class MullvadVpnService : BackgroundService
|
||||
{
|
||||
private readonly TerminalLogger _logger;
|
||||
private ServerSettings _settings;
|
||||
private readonly string _settingsPath = "server_settings.xml";
|
||||
private bool _isConnected = false;
|
||||
private int _consecutiveFailures = 0;
|
||||
private readonly int _maxRetries = 3;
|
||||
|
||||
public bool IsConnected => _isConnected;
|
||||
|
||||
public MullvadVpnService(TerminalLogger logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_settings = ServerSettings.Load(_settingsPath);
|
||||
}
|
||||
|
||||
public void ReloadSettings()
|
||||
{
|
||||
_settings = ServerSettings.Load(_settingsPath);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (_settings.VpnEnabled)
|
||||
{
|
||||
await HealthCheckAsync();
|
||||
}
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> HealthCheckAsync()
|
||||
{
|
||||
if (!_settings.VpnEnabled) return true;
|
||||
|
||||
string status = await RunCliAsync("status");
|
||||
if (status.Contains("Connected"))
|
||||
{
|
||||
_isConnected = true;
|
||||
_consecutiveFailures = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try reconnecting
|
||||
_logger.Warning("VPN is not connected. Attempting to reconnect...");
|
||||
if (await ConnectAsync()) return true;
|
||||
|
||||
_consecutiveFailures++;
|
||||
if (_consecutiveFailures >= _maxRetries)
|
||||
{
|
||||
_logger.Error($"VPN unrecoverable after {_maxRetries} retries.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> ConnectAsync()
|
||||
{
|
||||
if (!_settings.VpnEnabled) return true;
|
||||
|
||||
string status = await RunCliAsync("status");
|
||||
if (status.Contains("Connected"))
|
||||
{
|
||||
if (string.IsNullOrEmpty(_settings.VpnLocation) || status.Contains(_settings.VpnLocation, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.Info($"VPN already connected tightly to {_settings.VpnLocation}");
|
||||
_isConnected = true;
|
||||
_consecutiveFailures = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.MullvadAccount))
|
||||
{
|
||||
await RunCliAsync($"account login {_settings.MullvadAccount}");
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
|
||||
await RunCliAsync("lan set allow");
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.VpnLocation))
|
||||
{
|
||||
await RunCliAsync($"relay set location {_settings.VpnLocation}");
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
|
||||
await RunCliAsync("connect");
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
await Task.Delay(2000);
|
||||
string check = await RunCliAsync("status");
|
||||
if (check.Contains("Connected"))
|
||||
{
|
||||
_isConnected = true;
|
||||
_consecutiveFailures = 0;
|
||||
_logger.Info($"VPN connected successfully: {check.Trim()}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Error("VPN failed to connect after waiting");
|
||||
_consecutiveFailures++;
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<bool> DisconnectAsync()
|
||||
{
|
||||
string result = await RunCliAsync("disconnect");
|
||||
_isConnected = false;
|
||||
_logger.Info("VPN disconnected.");
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<string> RunCliAsync(string args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_settings.MullvadCliPath))
|
||||
{
|
||||
_logger.Error($"Mullvad CLI not found at: {_settings.MullvadCliPath}");
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = _settings.MullvadCliPath,
|
||||
Arguments = args,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null) return string.Empty;
|
||||
|
||||
await process.WaitForExitAsync();
|
||||
string output = await process.StandardOutput.ReadToEndAsync();
|
||||
string err = await process.StandardError.ReadToEndAsync();
|
||||
return string.IsNullOrWhiteSpace(output) ? err : output;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Mullvad CLI exc: {ex.Message}");
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Threading.Channels;
|
||||
using MongoDB.Driver;
|
||||
using PolyTraderSharp.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PolyTraderSharp.Models;
|
||||
|
||||
namespace PolyTraderSharp.Services
|
||||
{
|
||||
public class PersistenceService : BackgroundService
|
||||
{
|
||||
private readonly ChannelReader<ClosedTrade> _tradeReader;
|
||||
private readonly IMongoDatabase _db;
|
||||
private readonly TerminalLogger _logger;
|
||||
private readonly JobStatusRow _jobStatus;
|
||||
|
||||
public PersistenceService(ChannelReader<ClosedTrade> tradeReader, IMongoDatabase db, TerminalLogger logger, JobManager jobManager)
|
||||
{
|
||||
_tradeReader = tradeReader;
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
|
||||
_jobStatus = new JobStatusRow
|
||||
{
|
||||
JobName = "MongoDB Transaction Log",
|
||||
Description = "Awaits internal signals to write Closed Trades to the database safely.",
|
||||
StatusText = "Pending Initial Delay..."
|
||||
};
|
||||
|
||||
_jobStatus.ManualTriggerAction = async () =>
|
||||
{
|
||||
_jobStatus.StatusText = "Manual trigger not supported for Channel Reader";
|
||||
await Task.Delay(2000);
|
||||
_jobStatus.StatusText = "Listening (Channel)...";
|
||||
};
|
||||
|
||||
jobManager.RegisterJob(_jobStatus);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.Info("PersistenceService started writing background DB logs.");
|
||||
_jobStatus.StatusText = "Listening (Channel)...";
|
||||
|
||||
// One-time index setup (moved out of hot loop)
|
||||
var col = _db.GetCollection<ClosedTrade>("closed_trades");
|
||||
col.EnsureIndex(x => x.TradeId);
|
||||
col.EnsureIndex(x => x.AccountId);
|
||||
col.EnsureIndex(x => x.TokenId);
|
||||
|
||||
// We do a loop waiting for items in the channel
|
||||
await foreach(var trade in _tradeReader.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
if (!_jobStatus.IsEnabled)
|
||||
{
|
||||
// If paused, we just drop the trade for now or log a warning
|
||||
_logger.Warning("PersistenceService is paused, ignoring trade log.");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_jobStatus.StatusText = "Writing to DB...";
|
||||
|
||||
// ===== DEDUPLIZIERUNG: Verhindert das Mehrfach-Einfügen desselben Trades =====
|
||||
// Prüft ob für diesen Account + TokenId bereits ein ClosedTrade existiert.
|
||||
// Dies verhindert den "Background Sync Duplicate Bug", bei dem geschlossene
|
||||
// Trades bei jedem Sync-Zyklus oder nach einem Neustart erneut eingefügt werden.
|
||||
if (!string.IsNullOrEmpty(trade.TokenId))
|
||||
{
|
||||
var existing = col.LiteFindOne(x => x.AccountId == trade.AccountId && x.TokenId == trade.TokenId);
|
||||
if (existing != null)
|
||||
{
|
||||
_logger.Debug($"Duplikat ignoriert: ClosedTrade für Account {trade.AccountId} + Token {trade.TokenId.Substring(0, Math.Min(10, trade.TokenId.Length))}... existiert bereits (DB-ID: {existing.TradeId}).");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
col.Insert(trade);
|
||||
|
||||
_logger.Debug($"Saved ClosedTrade {trade.TradeId} to MongoDB");
|
||||
_jobStatus.LastRun = DateTime.Now;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to persist ClosedTrade (ID: {trade.TradeId}): {ex.Message}");
|
||||
_jobStatus.StatusText = "Error!";
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_jobStatus.StatusText != "Error!")
|
||||
_jobStatus.StatusText = "Listening (Channel)...";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,943 @@
|
||||
using System;
|
||||
using MongoDB.Driver;
|
||||
using PolyTraderSharp.Extensions;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PolyTraderSharp.Services
|
||||
{
|
||||
public class PolymarketApiService
|
||||
{
|
||||
private readonly TerminalLogger _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
private readonly string _dataHost = "https://data-api.polymarket.com";
|
||||
private readonly string _clobHost = "https://clob.polymarket.com";
|
||||
|
||||
// Per-endpoint rate tracking (10-second windows matching Polymarket limits)
|
||||
// Data API has per-endpoint limits that are stricter than the general 1000/10s
|
||||
private readonly ConcurrentQueue<DateTime> _dataActivityTimestamps = new(); // /activity → General 1000/10s
|
||||
private readonly ConcurrentQueue<DateTime> _dataPositionsTimestamps = new(); // /positions → 150/10s
|
||||
private readonly ConcurrentQueue<DateTime> _gammaApiTimestamps = new(); // /events → 500/10s
|
||||
private readonly ConcurrentQueue<DateTime> _clobApiTimestamps = new(); // General 9000/10s
|
||||
private long _lastPingMs = 0;
|
||||
|
||||
// Polymarket documented rate limits per 10 seconds (per endpoint we use)
|
||||
public static readonly Dictionary<string, int> RateLimits = new()
|
||||
{
|
||||
{ "Activity", 1000 }, // Data API /activity (General limit, no specific)
|
||||
{ "Positions", 150 }, // Data API /positions (specific endpoint limit!)
|
||||
{ "Gamma", 500 }, // Gamma API /events (specific endpoint limit)
|
||||
{ "CLOB", 9000 } // CLOB API General
|
||||
};
|
||||
|
||||
public PolymarketApiService(TerminalLogger logger, HttpClient httpClient)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClient = httpClient;
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
|
||||
_httpClient.Timeout = TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
public long GetLastPing() => _lastPingMs;
|
||||
|
||||
/// <summary>
|
||||
/// Returns per-endpoint request counts in the last 10 seconds.
|
||||
/// Keys match the RateLimits dictionary.
|
||||
/// </summary>
|
||||
public Dictionary<string, int> GetRateLimitsPerTenSeconds()
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddSeconds(-10);
|
||||
return new Dictionary<string, int>
|
||||
{
|
||||
{ "Activity", CountRecent(_dataActivityTimestamps, cutoff) },
|
||||
{ "Positions", CountRecent(_dataPositionsTimestamps, cutoff) },
|
||||
{ "Gamma", CountRecent(_gammaApiTimestamps, cutoff) },
|
||||
{ "CLOB", CountRecent(_clobApiTimestamps, cutoff) }
|
||||
};
|
||||
}
|
||||
|
||||
private static int CountRecent(ConcurrentQueue<DateTime> queue, DateTime cutoff)
|
||||
{
|
||||
int count = 0;
|
||||
foreach (var dt in queue.ToArray())
|
||||
{
|
||||
if (dt >= cutoff) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private void TrackRequest(string apiType)
|
||||
{
|
||||
var queue = apiType switch
|
||||
{
|
||||
"Activity" => _dataActivityTimestamps,
|
||||
"Positions" => _dataPositionsTimestamps,
|
||||
"Gamma" => _gammaApiTimestamps,
|
||||
"CLOB" => _clobApiTimestamps,
|
||||
_ => _clobApiTimestamps
|
||||
};
|
||||
queue.Enqueue(DateTime.UtcNow);
|
||||
while (queue.TryPeek(out DateTime oldest) && oldest < DateTime.UtcNow.AddSeconds(-10))
|
||||
{
|
||||
queue.TryDequeue(out _);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> MeasurePingAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
TrackRequest("CLOB");
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var response = await _httpClient.GetAsync($"{_clobHost}/time");
|
||||
sw.Stop();
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_lastPingMs = sw.ElapsedMilliseconds;
|
||||
return (int)_lastPingMs;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return -1;
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> GetWithRetryAsync(string url)
|
||||
{
|
||||
int maxRetries = 3;
|
||||
for (int i = 0; i < maxRetries; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetAsync(url);
|
||||
if ((int)response.StatusCode == 429) // Rate limit
|
||||
{
|
||||
var delay = Math.Pow(2, i + 1);
|
||||
_logger.Warning($"API Rate-Limit (429) auf {url}. Retry in {delay}s...");
|
||||
await Task.Delay(TimeSpan.FromSeconds(delay));
|
||||
continue;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
if (i == maxRetries - 1) throw;
|
||||
var delay = Math.Pow(2, i + 1);
|
||||
_logger.Warning($"API Timeout auf {url}. Retry in {delay}s...");
|
||||
await Task.Delay(TimeSpan.FromSeconds(delay));
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
if (i == maxRetries - 1) throw;
|
||||
var delay = Math.Pow(2, i + 1);
|
||||
_logger.Warning($"Netzwerkfehler auf {url}. Retry in {delay}s...");
|
||||
await Task.Delay(TimeSpan.FromSeconds(delay));
|
||||
}
|
||||
}
|
||||
return await _httpClient.GetAsync(url); //Fallback
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the recent trading activity for a given wallet address.
|
||||
/// </summary>
|
||||
public async Task<List<JsonElement>> GetTraderActivityAsync(string walletAddress, int limit = 50)
|
||||
{
|
||||
TrackRequest("Activity");
|
||||
try
|
||||
{
|
||||
long cb = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
string url = $"{_dataHost}/activity?limit={limit}&user={walletAddress}&type=TRADE&_cb={cb}";
|
||||
using var response = await GetWithRetryAsync(url);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.Warning($"API returned {response.StatusCode} for {walletAddress}");
|
||||
return new List<JsonElement>();
|
||||
}
|
||||
|
||||
var jsonStr = await response.Content.ReadAsStringAsync();
|
||||
using var document = JsonDocument.Parse(jsonStr);
|
||||
|
||||
var list = new List<JsonElement>();
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var element in document.RootElement.EnumerateArray())
|
||||
{
|
||||
list.Add(element.Clone());
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to fetch activity for {walletAddress}: {ex.Message}");
|
||||
return new List<JsonElement>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the current best price from the CLOB orderbook for a given token.
|
||||
/// For SELL: returns the best bid (highest buy offer).
|
||||
/// For BUY: returns the best ask (lowest sell offer).
|
||||
/// </summary>
|
||||
public async Task<decimal?> GetOrderBookPriceAsync(string tokenId, string side = "SELL")
|
||||
{
|
||||
TrackRequest("CLOB");
|
||||
try
|
||||
{
|
||||
string url = $"{_clobHost}/book?token_id={tokenId}";
|
||||
using var response = await GetWithRetryAsync(url);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.Warning($"Orderbook request failed: {response.StatusCode}");
|
||||
return null;
|
||||
}
|
||||
|
||||
var jsonStr = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(jsonStr);
|
||||
|
||||
// For SELL we want the best bid (buyer's highest price)
|
||||
// For BUY we want the best ask (seller's lowest price)
|
||||
string bookSide = side.ToUpper() == "SELL" ? "bids" : "asks";
|
||||
|
||||
if (doc.RootElement.TryGetProperty(bookSide, out var orders) &&
|
||||
orders.ValueKind == JsonValueKind.Array && orders.GetArrayLength() > 0)
|
||||
{
|
||||
var priceList = new List<decimal>();
|
||||
foreach (var order in orders.EnumerateArray())
|
||||
{
|
||||
if (order.TryGetProperty("price", out var priceProp))
|
||||
{
|
||||
string priceStr = priceProp.GetString() ?? "";
|
||||
if (decimal.TryParse(priceStr, System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out decimal p))
|
||||
{
|
||||
priceList.Add(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (priceList.Count > 0)
|
||||
{
|
||||
// Seller wants the highest bid. Buyer wants the lowest ask.
|
||||
if (side.ToUpper() == "SELL") return priceList.Max();
|
||||
else return priceList.Min();
|
||||
}
|
||||
}
|
||||
|
||||
_logger.Warning($"Orderbook leer oder kein Preis gefunden für Token {tokenId}");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"GetOrderBookPriceAsync Fehler: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<decimal> GetUsdcBalanceAsync(string walletAddress)
|
||||
{
|
||||
if (string.IsNullOrEmpty(walletAddress)) return 0;
|
||||
decimal totalBalance = 0;
|
||||
try
|
||||
{
|
||||
string addressObj = walletAddress.Replace("0x", "").PadLeft(64, '0');
|
||||
string data = "0x70a08231" + addressObj;
|
||||
|
||||
string[] rpcs = { "https://polygon-rpc.com", "https://polygon.llamarpc.com", "https://rpc.ankr.com/polygon" };
|
||||
string[] contracts = { "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" };
|
||||
|
||||
foreach (var usdcContract in contracts)
|
||||
{
|
||||
bool success = false;
|
||||
foreach (var rpcUrl in rpcs)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
jsonrpc = "2.0",
|
||||
method = "eth_call",
|
||||
@params = new object[]
|
||||
{
|
||||
new { to = usdcContract, data },
|
||||
"latest"
|
||||
},
|
||||
id = 1
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var content = new StringContent(JsonSerializer.Serialize(payload), System.Text.Encoding.UTF8, "application/json");
|
||||
using var response = await _httpClient.PostAsync(rpcUrl, content);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("result", out var res) && res.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
string hexBal = res.GetString() ?? "0x0";
|
||||
if (hexBal.StartsWith("0x")) hexBal = hexBal.Substring(2);
|
||||
if (!string.IsNullOrEmpty(hexBal))
|
||||
{
|
||||
long rawBalance = Convert.ToInt64(hexBal, 16);
|
||||
decimal pVal = (decimal)rawBalance / 1_000_000m;
|
||||
totalBalance += pVal;
|
||||
if (pVal > 0) _logger.Info($"🌐 [{walletAddress.Substring(0, 6)}...] Balance gefunden: ${pVal:F2} auf Contract {usdcContract}");
|
||||
}
|
||||
}
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception exInner) { _logger.Error($"USDC Balance RPC Exception on {rpcUrl}: {exInner.Message}"); }
|
||||
}
|
||||
if (!success) _logger.Warning($"Fehler beim Abruf von USDC Token {usdcContract}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"USDC Balance fetch failed for {walletAddress}: {ex.Message}");
|
||||
}
|
||||
return totalBalance;
|
||||
}
|
||||
|
||||
public async Task<(bool isResolved, bool isWinner)> CheckMarketResolutionAsync(string slug, string tokenId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tokenId)) return (false, false);
|
||||
try
|
||||
{
|
||||
TrackRequest("Gamma");
|
||||
using var response = await GetWithRetryAsync($"https://gamma-api.polymarket.com/markets?clob_token_ids={tokenId}");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var jsonStr = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(jsonStr);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0)
|
||||
{
|
||||
var mkt = doc.RootElement[0];
|
||||
|
||||
bool mktClosed = mkt.TryGetProperty("closed", out var mc) && mc.GetBoolean();
|
||||
if (!mktClosed) return (false, false);
|
||||
|
||||
if (mkt.TryGetProperty("clobTokenIds", out var cIdsStr) && mkt.TryGetProperty("outcomePrices", out var pricesStr))
|
||||
{
|
||||
using var cDoc = JsonDocument.Parse(cIdsStr.GetString() ?? "[]");
|
||||
using var pDoc = JsonDocument.Parse(pricesStr.GetString() ?? "[]");
|
||||
|
||||
var ids = cDoc.RootElement.EnumerateArray().ToList();
|
||||
var prices = pDoc.RootElement.EnumerateArray().ToList();
|
||||
|
||||
for (int i = 0; i < ids.Count; i++)
|
||||
{
|
||||
if (ids[i].GetString() == tokenId)
|
||||
{
|
||||
if (i < prices.Count)
|
||||
{
|
||||
if (decimal.TryParse(prices[i].GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var px) && px >= 0.99m)
|
||||
return (true, true);
|
||||
else
|
||||
return (true, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error checking market resolution for token {tokenId}: {ex.Message}");
|
||||
}
|
||||
return (false, false);
|
||||
}
|
||||
|
||||
public async Task<List<JsonElement>?> SyncOpenPositionsAsync(string walletAddress)
|
||||
{
|
||||
if (string.IsNullOrEmpty(walletAddress)) return new List<JsonElement>();
|
||||
try
|
||||
{
|
||||
var allPositions = new List<JsonElement>();
|
||||
int limit = 500;
|
||||
int offset = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
TrackRequest("Positions");
|
||||
using var response = await GetWithRetryAsync($"https://data-api.polymarket.com/positions?user={walletAddress}&limit={limit}&offset={offset}");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
int count = 0;
|
||||
foreach (var el in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
allPositions.Add(el.Clone());
|
||||
count++;
|
||||
}
|
||||
if (count < limit) break; // Reached the end
|
||||
offset += limit;
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Error($"Failed to fetch open positions (HTTP {(int)response.StatusCode}): {response.ReasonPhrase}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return allPositions.Count > 0 ? allPositions : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to fetch open positions for {walletAddress}: {ex.Message}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the current position sizes a master trader holds for a set of token IDs.
|
||||
/// Returns a Dictionary mapping TokenId -> Shares held. Only includes tokens with size > 0.
|
||||
/// </summary>
|
||||
public async Task<Dictionary<string, decimal>> GetTraderPositionSizesAsync(string walletAddress, HashSet<string> relevantTokenIds)
|
||||
{
|
||||
var result = new Dictionary<string, decimal>();
|
||||
if (string.IsNullOrEmpty(walletAddress) || relevantTokenIds.Count == 0) return result;
|
||||
|
||||
try
|
||||
{
|
||||
int limit = 500;
|
||||
int offset = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
TrackRequest("Positions");
|
||||
using var response = await GetWithRetryAsync($"https://data-api.polymarket.com/positions?user={walletAddress}&limit={limit}&offset={offset}&sizeThreshold=0.1");
|
||||
if (!response.IsSuccessStatusCode) break;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array) break;
|
||||
|
||||
int count = 0;
|
||||
foreach (var el in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
count++;
|
||||
string asset = el.TryGetProperty("asset", out var ap) ? ap.GetString() ?? "" : "";
|
||||
if (!string.IsNullOrEmpty(asset) && relevantTokenIds.Contains(asset))
|
||||
{
|
||||
decimal size = 0;
|
||||
if (el.TryGetProperty("size", out var sp))
|
||||
{
|
||||
if (sp.ValueKind == JsonValueKind.Number) size = sp.GetDecimal();
|
||||
else if (sp.ValueKind == JsonValueKind.String) decimal.TryParse(sp.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out size);
|
||||
}
|
||||
if (size > 0) result[asset] = size;
|
||||
}
|
||||
}
|
||||
|
||||
if (count < limit) break; // Reached end
|
||||
offset += limit;
|
||||
if (offset > 5000) break; // Safety cap
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to fetch trader positions for {walletAddress}: {ex.Message}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<List<JsonElement>> SyncClosedPositionsAsync(string walletAddress, int limit = 100)
|
||||
{
|
||||
if (string.IsNullOrEmpty(walletAddress)) return new List<JsonElement>();
|
||||
try
|
||||
{
|
||||
TrackRequest("Positions");
|
||||
using var response = await GetWithRetryAsync($"https://data-api.polymarket.com/closed-positions?user={walletAddress}&limit={limit}&sortBy=TIMESTAMP&sortDirection=DESC");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
var list = new List<JsonElement>();
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var el in doc.RootElement.EnumerateArray())
|
||||
list.Add(el.Clone());
|
||||
}
|
||||
return list;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Error($"Failed to fetch closed positions (HTTP {(int)response.StatusCode}): {response.ReasonPhrase}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to fetch closed positions for {walletAddress}: {ex.Message}");
|
||||
}
|
||||
return new List<JsonElement>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Future placeholder for Live trading (Requires CLOB credentials context).
|
||||
/// </summary>
|
||||
public async Task<bool> PlaceOrderAsync(int accountId, string tokenId, decimal price, decimal size, string side)
|
||||
{
|
||||
TrackRequest("CLOB");
|
||||
_logger.Info($"Placing {side} order on Account {accountId} for Token {tokenId}. Size: {size} @ {price}");
|
||||
|
||||
await Task.Delay(100);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<List<PolyTraderSharp.Models.MarketData>> GetRecentMarketsAsync(int limit = 1000)
|
||||
{
|
||||
TrackRequest("Gamma");
|
||||
var results = new List<PolyTraderSharp.Models.MarketData>();
|
||||
try
|
||||
{
|
||||
string url = $"https://gamma-api.polymarket.com/markets?limit={limit}&order=id&ascending=false";
|
||||
using var response = await GetWithRetryAsync(url);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var jsonStr = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(jsonStr);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var mkt in doc.RootElement.EnumerateArray())
|
||||
{
|
||||
var md = new PolyTraderSharp.Models.MarketData();
|
||||
md.Id = mkt.TryGetProperty("id", out var p1) ? p1.GetString() ?? "" : "";
|
||||
md.ConditionId = mkt.TryGetProperty("conditionId", out var p2) ? p2.GetString() ?? "" : "";
|
||||
md.Question = mkt.TryGetProperty("question", out var p3) ? p3.GetString() ?? "" : "";
|
||||
|
||||
md.Active = mkt.TryGetProperty("active", out var p5) && p5.GetBoolean();
|
||||
md.Closed = mkt.TryGetProperty("closed", out var p6) && p6.GetBoolean();
|
||||
md.ClobTokenIds = mkt.TryGetProperty("clobTokenIds", out var p7) ? p7.GetString() ?? "" : "";
|
||||
|
||||
md.Slug = mkt.TryGetProperty("slug", out var p4) ? p4.GetString() ?? "" : "";
|
||||
if (mkt.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array && evts.GetArrayLength() > 0)
|
||||
{
|
||||
var evSlug = evts[0].TryGetProperty("slug", out var evp) ? evp.GetString() : "";
|
||||
if (!string.IsNullOrEmpty(evSlug)) md.Slug = evSlug;
|
||||
|
||||
md.NegRisk = evts[0].TryGetProperty("enableNegRisk", out var pNeg) && pNeg.ValueKind == JsonValueKind.True;
|
||||
}
|
||||
|
||||
if (mkt.TryGetProperty("endDate", out var ep) && ep.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTime.TryParse(ep.GetString(), null, System.Globalization.DateTimeStyles.RoundtripKind, out var endDt))
|
||||
{
|
||||
md.EndDate = endDt.ToUniversalTime();
|
||||
}
|
||||
}
|
||||
|
||||
results.Add(md);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to fetch recent markets: {ex.Message}");
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<PolyTraderSharp.Models.MarketData?> GetMarketByTokenIdAsync(string tokenId)
|
||||
{
|
||||
TrackRequest("Gamma");
|
||||
if (string.IsNullOrEmpty(tokenId)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
// Must use clob_token_ids! If you use clobTokenIds it ignores it and returns the oldest market (Joe Biden)
|
||||
string url = $"https://gamma-api.polymarket.com/markets?clob_token_ids={tokenId}";
|
||||
using var response = await GetWithRetryAsync(url);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var jsonStr = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(jsonStr);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0)
|
||||
{
|
||||
var mkt = doc.RootElement[0];
|
||||
var md = new PolyTraderSharp.Models.MarketData();
|
||||
md.Id = mkt.TryGetProperty("id", out var p1) ? p1.GetString() ?? "" : "";
|
||||
md.ConditionId = mkt.TryGetProperty("conditionId", out var p2) ? p2.GetString() ?? "" : "";
|
||||
md.Question = mkt.TryGetProperty("question", out var p3) ? p3.GetString() ?? "" : "";
|
||||
|
||||
md.Active = mkt.TryGetProperty("active", out var p5) && p5.GetBoolean();
|
||||
md.Closed = mkt.TryGetProperty("closed", out var p6) && p6.GetBoolean();
|
||||
md.ClobTokenIds = mkt.TryGetProperty("clobTokenIds", out var p7) ? (p7.ValueKind == JsonValueKind.String ? p7.GetString() ?? "" : p7.GetRawText()) : "";
|
||||
md.Outcomes = mkt.TryGetProperty("outcomes", out var p8) ? (p8.ValueKind == JsonValueKind.String ? p8.GetString() ?? "" : p8.GetRawText()) : "";
|
||||
|
||||
md.Slug = mkt.TryGetProperty("slug", out var p4) ? p4.GetString() ?? "" : "";
|
||||
if (mkt.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array && evts.GetArrayLength() > 0)
|
||||
{
|
||||
var evt = evts[0];
|
||||
var evSlug = evt.TryGetProperty("slug", out var evp) ? evp.GetString() : "";
|
||||
if (!string.IsNullOrEmpty(evSlug)) md.Slug = evSlug;
|
||||
|
||||
if (evt.TryGetProperty("enableNegRisk", out var pNeg) && pNeg.ValueKind == JsonValueKind.True)
|
||||
{
|
||||
md.NegRisk = true;
|
||||
}
|
||||
|
||||
if (evt.TryGetProperty("endDate", out var et) && DateTime.TryParse(et.GetString(), out var dt))
|
||||
{
|
||||
md.EndDate = DateTime.SpecifyKind(dt, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (mkt.TryGetProperty("endDate", out var et) && DateTime.TryParse(et.GetString(), out var dt))
|
||||
{
|
||||
md.EndDate = DateTime.SpecifyKind(dt, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
|
||||
// Security Validation: Ensure the API actually returned the market we asked for!
|
||||
if (string.IsNullOrEmpty(md.ClobTokenIds) || !md.ClobTokenIds.Contains(tokenId))
|
||||
{
|
||||
_logger.Warning($"GetMarketByTokenIdAsync: API returned a mismatching market '{md.Question}' for Token {tokenId}. Skipping.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return md;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to fetch market by token ID ({tokenId}): {ex.Message}");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<List<PolyTraderSharp.Models.MarketData>> GetMarketsByEventSlugAsync(string slug)
|
||||
{
|
||||
TrackRequest("Gamma");
|
||||
var results = new List<PolyTraderSharp.Models.MarketData>();
|
||||
if (string.IsNullOrEmpty(slug)) return results;
|
||||
|
||||
try
|
||||
{
|
||||
string url = $"https://gamma-api.polymarket.com/events?slug={slug}";
|
||||
using var response = await GetWithRetryAsync(url);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var jsonStr = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(jsonStr);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0)
|
||||
{
|
||||
var ev = doc.RootElement[0];
|
||||
if (ev.TryGetProperty("markets", out var marketsArr) && marketsArr.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var mkt in marketsArr.EnumerateArray())
|
||||
{
|
||||
var md = new PolyTraderSharp.Models.MarketData();
|
||||
md.Id = mkt.TryGetProperty("id", out var p1) ? p1.GetString() ?? "" : "";
|
||||
md.ConditionId = mkt.TryGetProperty("conditionId", out var p2) ? p2.GetString() ?? "" : "";
|
||||
md.Question = mkt.TryGetProperty("question", out var p3) ? p3.GetString() ?? "" : "";
|
||||
md.Active = mkt.TryGetProperty("active", out var p5) && p5.GetBoolean();
|
||||
md.Closed = mkt.TryGetProperty("closed", out var p6) && p6.GetBoolean();
|
||||
md.ClobTokenIds = mkt.TryGetProperty("clobTokenIds", out var p7) ? (p7.ValueKind == JsonValueKind.String ? p7.GetString() ?? "" : p7.GetRawText()) : "";
|
||||
md.Outcomes = mkt.TryGetProperty("outcomes", out var p8) ? (p8.ValueKind == JsonValueKind.String ? p8.GetString() ?? "" : p8.GetRawText()) : "";
|
||||
md.Slug = slug;
|
||||
|
||||
md.NegRisk = ev.TryGetProperty("enableNegRisk", out var evNeg) && evNeg.ValueKind == JsonValueKind.True;
|
||||
|
||||
if (mkt.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array && evts.GetArrayLength() > 0)
|
||||
{
|
||||
var evSlug = evts[0].TryGetProperty("slug", out var evp) ? evp.GetString() : "";
|
||||
if (!string.IsNullOrEmpty(evSlug)) md.Slug = evSlug;
|
||||
|
||||
// if missing on event root but present in nested events (rare), fallback to it
|
||||
if (!md.NegRisk && evts[0].TryGetProperty("enableNegRisk", out var pNeg) && pNeg.ValueKind == JsonValueKind.True)
|
||||
{
|
||||
md.NegRisk = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (mkt.TryGetProperty("endDate", out var ep) && ep.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
if (DateTime.TryParse(ep.GetString(), null, System.Globalization.DateTimeStyles.RoundtripKind, out var endDt))
|
||||
{
|
||||
md.EndDate = endDt.ToUniversalTime();
|
||||
}
|
||||
}
|
||||
|
||||
results.Add(md);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to fetch markets by slug ({slug}): {ex.Message}");
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<string> ResolveEventSlugAsync(string fallbackSlug, string tokenId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(tokenId)) return fallbackSlug;
|
||||
|
||||
try
|
||||
{
|
||||
// Gamma API will resolve the market object along with its parent event properties
|
||||
string url = $"https://gamma-api.polymarket.com/markets?clob_token_ids={tokenId}";
|
||||
using var response = await GetWithRetryAsync(url);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var jsonStr = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(jsonStr);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0)
|
||||
{
|
||||
var mkt = doc.RootElement[0];
|
||||
if (mkt.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array && evts.GetArrayLength() > 0)
|
||||
{
|
||||
var evSlug = evts[0].TryGetProperty("slug", out var evs) ? evs.GetString() : "";
|
||||
if (!string.IsNullOrEmpty(evSlug)) return evSlug;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error resolving Event Slug for Token {tokenId}: {ex.Message}");
|
||||
}
|
||||
|
||||
return fallbackSlug;
|
||||
}
|
||||
|
||||
|
||||
public async Task<List<PolyTraderSharp.Models.CopySignal>> ParseBlockchainTransactionAsync(string txHash, string rpcUrl, string masterWallet)
|
||||
{
|
||||
var results = new List<PolyTraderSharp.Models.CopySignal>();
|
||||
try
|
||||
{
|
||||
// Convert wss:// to https://
|
||||
if (rpcUrl.StartsWith("wss://")) rpcUrl = "https://" + rpcUrl.Substring(6);
|
||||
|
||||
var rpcPayload = new
|
||||
{
|
||||
jsonrpc = "2.0",
|
||||
method = "eth_getTransactionReceipt",
|
||||
@params = new object[] { txHash },
|
||||
id = 1
|
||||
};
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, rpcUrl);
|
||||
request.Content = new StringContent(JsonSerializer.Serialize(rpcPayload), System.Text.Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await _httpClient.SendAsync(request);
|
||||
if (!response.IsSuccessStatusCode) return results;
|
||||
|
||||
var jsonStr = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(jsonStr);
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("result", out var result) || result.ValueKind != JsonValueKind.Object)
|
||||
return results;
|
||||
|
||||
if (!result.TryGetProperty("logs", out var logs) || logs.ValueKind != JsonValueKind.Array)
|
||||
return results;
|
||||
|
||||
string rxFrom = "";
|
||||
if (result.TryGetProperty("from", out var fVal) && fVal.ValueKind == JsonValueKind.String)
|
||||
rxFrom = fVal.GetString()?.ToLowerInvariant() ?? "";
|
||||
|
||||
decimal usdcAmount = 0m;
|
||||
string action = "";
|
||||
|
||||
// Track parsed CTF transfers: Dictionary<TokenId, Shares>
|
||||
var parsedTransfers = new Dictionary<string, decimal>();
|
||||
|
||||
string masterWalletLower = masterWallet.ToLowerInvariant().Replace("0x", "");
|
||||
string masterWalletPadded = "0x000000000000000000000000" + masterWalletLower;
|
||||
string ctfExchangePadded = "0x0000000000000000000000004bfb41d5b3570defd03c39a9a4d8de6bd8b8982e";
|
||||
bool isMasterTxOwner = rxFrom == ("0x" + masterWalletLower);
|
||||
|
||||
foreach (var log in logs.EnumerateArray())
|
||||
{
|
||||
string address = log.GetProperty("address").GetString()?.ToLowerInvariant() ?? "";
|
||||
|
||||
if (!log.TryGetProperty("topics", out var topicsArr) || topicsArr.ValueKind != JsonValueKind.Array || topicsArr.GetArrayLength() == 0) continue;
|
||||
|
||||
var topics = topicsArr.EnumerateArray().Select(t => t.GetString()?.ToLowerInvariant()).ToList();
|
||||
string data = log.GetProperty("data").GetString()?.ToLowerInvariant() ?? "0x";
|
||||
|
||||
string topic0 = topics[0] ?? "";
|
||||
|
||||
// USDC Transfer (or USDC.e)
|
||||
if (topic0 == "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
|
||||
{
|
||||
if (address == "0x2791bca1f2de4661ed88a30c99a7a9449aa84174" || address == "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359")
|
||||
{
|
||||
if (topics.Count >= 3)
|
||||
{
|
||||
string fromTopic = topics[1] ?? "";
|
||||
string toTopic = topics[2] ?? "";
|
||||
|
||||
if (isMasterTxOwner || fromTopic == masterWalletPadded || toTopic == masterWalletPadded || fromTopic == ctfExchangePadded || toTopic == ctfExchangePadded)
|
||||
{
|
||||
string cleanData = data.Replace("0x", "");
|
||||
if (cleanData.Length >= 64)
|
||||
{
|
||||
var amountBI = System.Numerics.BigInteger.Parse("0" + cleanData.Substring(0, 64), System.Globalization.NumberStyles.HexNumber);
|
||||
decimal amount = (decimal)amountBI / 1_000_000m; // 6 decimals USDC
|
||||
usdcAmount = Math.Max(usdcAmount, amount);
|
||||
|
||||
if (toTopic == ctfExchangePadded) action = "BUY";
|
||||
else if (fromTopic == ctfExchangePadded) action = "SELL";
|
||||
else if (fromTopic == masterWalletPadded) action = "BUY";
|
||||
else if (toTopic == masterWalletPadded) action = "SELL";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CTF TransferSingle
|
||||
if (topic0 == "0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62")
|
||||
{
|
||||
if (address == "0x4d97dcd97ec945f40cf65f87097ace5ea0476045")
|
||||
{
|
||||
if (topics.Count >= 4)
|
||||
{
|
||||
string fromTopic = topics[2] ?? "";
|
||||
string toTopic = topics[3] ?? "";
|
||||
|
||||
if (isMasterTxOwner || fromTopic == masterWalletPadded || toTopic == masterWalletPadded)
|
||||
{
|
||||
string cleanData = data.Replace("0x", "");
|
||||
if (cleanData.Length >= 128)
|
||||
{
|
||||
string idHex = cleanData.Substring(0, 64);
|
||||
string valueHex = cleanData.Substring(64, 64);
|
||||
|
||||
var idBI = System.Numerics.BigInteger.Parse("0" + idHex, System.Globalization.NumberStyles.HexNumber);
|
||||
var valueBI = System.Numerics.BigInteger.Parse("0" + valueHex, System.Globalization.NumberStyles.HexNumber);
|
||||
|
||||
string tid = idBI.ToString();
|
||||
decimal sh = (decimal)valueBI / 1_000_000m; // 6 decimals CTF
|
||||
|
||||
if (parsedTransfers.ContainsKey(tid)) parsedTransfers[tid] += sh;
|
||||
else parsedTransfers[tid] = sh;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CTF TransferBatch
|
||||
if (topic0 == "0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7ce")
|
||||
{
|
||||
if (address == "0x4d97dcd97ec945f40cf65f87097ace5ea0476045")
|
||||
{
|
||||
if (topics.Count >= 4)
|
||||
{
|
||||
string fromTopic = topics[2] ?? "";
|
||||
string toTopic = topics[3] ?? "";
|
||||
|
||||
if (isMasterTxOwner || fromTopic == masterWalletPadded || toTopic == masterWalletPadded)
|
||||
{
|
||||
string cleanData = data.Replace("0x", "");
|
||||
if (cleanData.Length >= 256)
|
||||
{
|
||||
try
|
||||
{
|
||||
var chunks = Enumerable.Range(0, cleanData.Length / 64).Select(i => cleanData.Substring(i * 64, 64)).ToList();
|
||||
if (chunks.Count >= 4)
|
||||
{
|
||||
int idsOffsetWord = int.Parse(chunks[0], System.Globalization.NumberStyles.HexNumber) / 32;
|
||||
int valsOffsetWord = int.Parse(chunks[1], System.Globalization.NumberStyles.HexNumber) / 32;
|
||||
|
||||
if (idsOffsetWord < chunks.Count && valsOffsetWord < chunks.Count)
|
||||
{
|
||||
int idsLen = int.Parse(chunks[idsOffsetWord], System.Globalization.NumberStyles.HexNumber);
|
||||
int valsLen = int.Parse(chunks[valsOffsetWord], System.Globalization.NumberStyles.HexNumber);
|
||||
|
||||
int maxLen = Math.Min(idsLen, valsLen);
|
||||
for (int i = 0; i < maxLen; i++)
|
||||
{
|
||||
if (idsOffsetWord + 1 + i < chunks.Count && valsOffsetWord + 1 + i < chunks.Count)
|
||||
{
|
||||
string idHex = chunks[idsOffsetWord + 1 + i];
|
||||
string valHex = chunks[valsOffsetWord + 1 + i];
|
||||
|
||||
var idBI = System.Numerics.BigInteger.Parse("0" + idHex, System.Globalization.NumberStyles.HexNumber);
|
||||
var valueBI = System.Numerics.BigInteger.Parse("0" + valHex, System.Globalization.NumberStyles.HexNumber);
|
||||
|
||||
string tid = idBI.ToString();
|
||||
decimal sh = (decimal)valueBI / 1_000_000m;
|
||||
|
||||
if (parsedTransfers.ContainsKey(tid)) parsedTransfers[tid] += sh;
|
||||
else parsedTransfers[tid] = sh;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error parsing TransferBatch for TX {txHash}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedTransfers.Count > 0 && !string.IsNullOrEmpty(action) && usdcAmount > 0)
|
||||
{
|
||||
decimal totalSharesForAllTokens = parsedTransfers.Values.Sum();
|
||||
decimal globalAvgPrice = totalSharesForAllTokens > 0 ? usdcAmount / totalSharesForAllTokens : 0;
|
||||
|
||||
// Filter: Redeems yield exactly $1.00 USD per share. Merges also yield $1.00 USD for a full set.
|
||||
// If the master trader "sells" at >= 0.99 on-chain, it is guaranteed to be a Redeem/Winnings Claim, NOT an orderbook trade.
|
||||
// We must filter this out so the copy trading engine doesn't dump our tickets at market price!
|
||||
if (action == "SELL" && globalAvgPrice >= 0.99m)
|
||||
{
|
||||
_logger.Debug($"FastTrack Parser: Ignored Fake SELL (Redeem/Merge) with Return Price ${globalAvgPrice:F3} for TX {txHash}");
|
||||
return results;
|
||||
}
|
||||
|
||||
if (globalAvgPrice > 0.999m) globalAvgPrice = 0.99m;
|
||||
|
||||
foreach (var pt in parsedTransfers)
|
||||
{
|
||||
var signal = new PolyTraderSharp.Models.CopySignal
|
||||
{
|
||||
TokenId = pt.Key,
|
||||
Side = action,
|
||||
Size = pt.Value,
|
||||
Price = globalAvgPrice,
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
results.Add(signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Blockchain Parser Error: {ex.Message}");
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,864 @@
|
||||
using System;
|
||||
using MongoDB.Driver;
|
||||
using PolyTraderSharp.Extensions;
|
||||
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('/', '_');
|
||||
}
|
||||
|
||||
private static long _serverTimeDeltaSeconds = 0;
|
||||
private static DateTime _lastTimeSync = DateTime.MinValue;
|
||||
|
||||
public async Task SyncServerTimeAsync()
|
||||
{
|
||||
if ((DateTime.UtcNow - _lastTimeSync).TotalMinutes < 15) return;
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync($"{ClobHost}/time");
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
string jsonStr = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(jsonStr);
|
||||
long epochSecs = 0;
|
||||
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
epochSecs = doc.RootElement.GetInt64();
|
||||
if (epochSecs > 1000000000000) epochSecs /= 1000;
|
||||
DateTime serverTime = DateTimeOffset.FromUnixTimeSeconds(epochSecs).UtcDateTime;
|
||||
_serverTimeDeltaSeconds = (long)(serverTime - DateTime.UtcNow).TotalSeconds;
|
||||
_lastTimeSync = DateTime.UtcNow;
|
||||
_logger.Info($"🕒 CLOB Server Time Sync: Offset ist {_serverTimeDeltaSeconds} Sekunden.");
|
||||
}
|
||||
else if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("iso", out var isoProp) && DateTime.TryParse(isoProp.GetString(), null, System.Globalization.DateTimeStyles.RoundtripKind, out DateTime serverTime))
|
||||
{
|
||||
serverTime = serverTime.ToUniversalTime();
|
||||
_serverTimeDeltaSeconds = (long)(serverTime - DateTime.UtcNow).TotalSeconds;
|
||||
_lastTimeSync = DateTime.UtcNow;
|
||||
_logger.Info($"🕒 CLOB Server Time Sync: Offset ist {_serverTimeDeltaSeconds} Sekunden.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"🕒 Time Sync Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private string GetClobTimestamp()
|
||||
{
|
||||
// Background fire-and-forget sync if expired
|
||||
if ((DateTime.UtcNow - _lastTimeSync).TotalMinutes > 15)
|
||||
{
|
||||
_ = SyncServerTimeAsync();
|
||||
}
|
||||
return (DateTimeOffset.UtcNow.ToUnixTimeSeconds() + _serverTimeDeltaSeconds).ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <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 = GetClobTimestamp();
|
||||
|
||||
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 = GetClobTimestamp();
|
||||
|
||||
// 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 = GetClobTimestamp();
|
||||
|
||||
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 = GetClobTimestamp();
|
||||
|
||||
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;
|
||||
|
||||
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 = orderType == "GTD" ? long.Parse(GetClobTimestamp()) + 300 : 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 = GetClobTimestamp();
|
||||
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 balMatch1 = System.Text.RegularExpressions.Regex.Match(responseContent, @"balance: (\d+), sum of active orders: (\d+)");
|
||||
var balMatch2 = System.Text.RegularExpressions.Regex.Match(responseContent, @"balance: (\d+), order amount: (\d+)");
|
||||
|
||||
if ((balMatch1.Success || balMatch2.Success) && sideStr == "SELL")
|
||||
{
|
||||
decimal totalBal = 0m, activeOrders = 0m;
|
||||
if (balMatch1.Success)
|
||||
{
|
||||
_ = decimal.TryParse(balMatch1.Groups[1].Value, out totalBal);
|
||||
_ = decimal.TryParse(balMatch1.Groups[2].Value, out activeOrders);
|
||||
}
|
||||
else if (balMatch2.Success)
|
||||
{
|
||||
_ = decimal.TryParse(balMatch2.Groups[1].Value, out totalBal);
|
||||
activeOrders = 0m;
|
||||
}
|
||||
|
||||
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 (Reale Balance: {totalBal / 1000000m} / Aktive Orders: {activeOrders / 1000000m} Shares). Verkaufe exakte {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($"\u2705 Order Platzierung Erfolgreich! {sideStr} @ {limitPrice:F3}");
|
||||
|
||||
if (orderType == "GTC" || orderType == "GTD")
|
||||
{
|
||||
account.HasOpenLimitOrders = true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,799 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,799 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
using System;
|
||||
using MongoDB.Driver;
|
||||
using PolyTraderSharp.Extensions;
|
||||
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;
|
||||
|
||||
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 IMongoDatabase _db;
|
||||
|
||||
// Tracking rate limits for auto redeem: max 2 attempts per position, 5 min apart
|
||||
private readonly ConcurrentDictionary<string, (int Count, DateTime LastAttempt)> _redeemAttempts = new();
|
||||
|
||||
public PolymarketWssClient(
|
||||
TradingState state,
|
||||
ServerSettings settings,
|
||||
PolymarketClobClient clob,
|
||||
TerminalLogger logger,
|
||||
IMongoDatabase 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(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal price);
|
||||
|
||||
decimal bestBid = price;
|
||||
if (change.TryGetProperty("best_bid", out var bidProp) && decimal.TryParse(bidProp.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, 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(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, 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: max 2 attempts per position, 5 minutes apart
|
||||
if (_redeemAttempts.TryGetValue(redeemKey, out var redeemState))
|
||||
{
|
||||
if (redeemState.Count >= 2) continue; // Permanently ignore after 2 failed attempts
|
||||
if ((DateTime.UtcNow - redeemState.LastAttempt).TotalMinutes < 5) continue; // Wait 5 min between attempts
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
string redeemKey = $"{acc.AccountId}_{pos.TokenId}";
|
||||
if (pos.Size < 5.0m)
|
||||
{
|
||||
var state = _redeemAttempts.GetOrAdd(redeemKey, _ => (0, DateTime.MinValue));
|
||||
int newCount = state.Count + 1;
|
||||
_redeemAttempts[redeemKey] = (newCount, DateTime.UtcNow);
|
||||
if (newCount <= 1) // Only log once
|
||||
_logger.Warning($"[AUTO REDEEM] Position {pos.MarketQuestion} zu klein für Limit Order (< 5 Shares). Max. 1 Retry in 5 Min.");
|
||||
return;
|
||||
}
|
||||
// Track successful attempt
|
||||
_redeemAttempts.AddOrUpdate(redeemKey, _ => (1, DateTime.UtcNow), (_, old) => (old.Count + 1, DateTime.UtcNow));
|
||||
|
||||
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.
|
||||
// DO NOT remove from OpenPositions here. Wait for Live Sync to detect the closure
|
||||
// via the API so it can properly fetch the Realized PnL and save the ClosedTrade record!
|
||||
}
|
||||
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.GetNextTradeId(),
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using PolyTraderSharp.Models;
|
||||
|
||||
namespace PolyTraderSharp.Services
|
||||
{
|
||||
public class SnapshotService : BackgroundService
|
||||
{
|
||||
private readonly TradingState _state;
|
||||
private readonly ILogger<SnapshotService> _logger;
|
||||
private readonly string _snapshotPath = "snapshot.json";
|
||||
private readonly TimeSpan _interval = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly JobStatusRow _jobStatus;
|
||||
|
||||
public SnapshotService(TradingState state, ILogger<SnapshotService> logger, JobManager jobManager)
|
||||
{
|
||||
_state = state;
|
||||
_logger = logger;
|
||||
|
||||
_jobStatus = new JobStatusRow
|
||||
{
|
||||
JobName = "LiteDB State Snapshot",
|
||||
Description = "Saves active application state (balances, open pos) to snapshot.json.",
|
||||
StatusText = "Pending Initial Delay..."
|
||||
};
|
||||
|
||||
_jobStatus.ManualTriggerAction = async () =>
|
||||
{
|
||||
string oldStatus = _jobStatus.StatusText;
|
||||
_jobStatus.StatusText = "Running (Manual)...";
|
||||
await SaveSnapshotAsync();
|
||||
_jobStatus.StatusText = "Idle";
|
||||
};
|
||||
|
||||
jobManager.RegisterJob(_jobStatus);
|
||||
}
|
||||
|
||||
public override async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Load state on startup
|
||||
if (File.Exists(_snapshotPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
string json = await File.ReadAllTextAsync(_snapshotPath, cancellationToken);
|
||||
var snapshot = JsonConvert.DeserializeObject<StateSnapshot>(json);
|
||||
|
||||
if (snapshot != null)
|
||||
{
|
||||
_state.LiveTradingMode = snapshot.LiveMode;
|
||||
_state.DemoTradingMode = snapshot.DemoMode;
|
||||
_state.TotalCopyTrades = snapshot.CopyTrades;
|
||||
_state.GlobalPnl = snapshot.GlobalPnl;
|
||||
|
||||
int restoredPositions = 0;
|
||||
// Restore OpenPositions to matching accounts
|
||||
foreach (var kvp in snapshot.OpenPositions)
|
||||
{
|
||||
if (_state.Accounts.TryGetValue(kvp.Key, out var acc))
|
||||
{
|
||||
foreach (var pos in kvp.Value)
|
||||
{
|
||||
acc.OpenPositions.TryAdd(pos.Key, pos.Value);
|
||||
restoredPositions++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Snapshot loaded. Restored {restoredPositions} positions. Modes: Live={snapshot.LiveMode}, Demo={snapshot.DemoMode}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to load snapshot on startup");
|
||||
}
|
||||
}
|
||||
|
||||
await base.StartAsync(cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_jobStatus.StatusText = "Idle";
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (_jobStatus.IsEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
_jobStatus.StatusText = "Running (Scheduled)...";
|
||||
await SaveSnapshotAsync();
|
||||
_jobStatus.LastRun = DateTime.Now;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving TradingState snapshot");
|
||||
_jobStatus.StatusText = "Error!";
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_jobStatus.StatusText != "Error!") _jobStatus.StatusText = "Idle";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_jobStatus.StatusText = "Paused";
|
||||
}
|
||||
|
||||
_jobStatus.NextRun = DateTime.Now.Add(_interval);
|
||||
await Task.Delay(_interval, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveSnapshotAsync()
|
||||
{
|
||||
var snapshot = new StateSnapshot
|
||||
{
|
||||
LiveMode = _state.LiveTradingMode,
|
||||
DemoMode = _state.DemoTradingMode,
|
||||
CopyTrades = _state.TotalCopyTrades,
|
||||
GlobalPnl = _state.GlobalPnl,
|
||||
OpenPositions = _state.Accounts.ToDictionary(
|
||||
a => a.Key,
|
||||
a => a.Value.OpenPositions.ToDictionary(p => p.Key, p => p.Value)
|
||||
)
|
||||
};
|
||||
|
||||
string json = JsonConvert.SerializeObject(snapshot, Formatting.Indented);
|
||||
await File.WriteAllTextAsync(_snapshotPath, json);
|
||||
|
||||
_logger.LogTrace("TradingState snapshot saved.");
|
||||
}
|
||||
|
||||
private class StateSnapshot
|
||||
{
|
||||
public TradingMode LiveMode { get; set; }
|
||||
public TradingMode DemoMode { get; set; }
|
||||
public int CopyTrades { get; set; }
|
||||
public decimal GlobalPnl { get; set; }
|
||||
public Dictionary<int, Dictionary<string, Position>> OpenPositions { get; set; } = new();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using MongoDB.Driver;
|
||||
using PolyTraderSharp.Extensions;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace PolyTraderSharp.Services
|
||||
{
|
||||
public enum LogLevel { Debug, Info, Warning, Error, Trade, TradeReasoning }
|
||||
|
||||
public class LogMessageEventArgs : EventArgs
|
||||
{
|
||||
public string Message { get; }
|
||||
public LogLevel Level { get; }
|
||||
public DateTime Timestamp { get; }
|
||||
|
||||
public LogMessageEventArgs(string message, LogLevel level)
|
||||
{
|
||||
Message = message;
|
||||
Level = level;
|
||||
Timestamp = DateTime.Now;
|
||||
}
|
||||
}
|
||||
|
||||
public class TerminalLogger
|
||||
{
|
||||
public event EventHandler<LogMessageEventArgs>? OnLogMessage;
|
||||
private readonly List<LogMessageEventArgs> _history = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
private readonly string _logsDirectory;
|
||||
private readonly Channel<LogMessageEventArgs> _logChannel;
|
||||
|
||||
public TerminalLogger()
|
||||
{
|
||||
_logsDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
|
||||
if (!Directory.Exists(_logsDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(_logsDirectory);
|
||||
}
|
||||
|
||||
_logChannel = Channel.CreateUnbounded<LogMessageEventArgs>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true
|
||||
});
|
||||
Task.Run(ProcessLogQueueAsync);
|
||||
}
|
||||
|
||||
private async Task ProcessLogQueueAsync()
|
||||
{
|
||||
await foreach (var e in _logChannel.Reader.ReadAllAsync())
|
||||
{
|
||||
try
|
||||
{
|
||||
string dateStr = e.Timestamp.ToString("dd-MM-yyyy");
|
||||
string fileName = $"{dateStr}-{e.Level}.log";
|
||||
string fullPath = Path.Combine(_logsDirectory, fileName);
|
||||
|
||||
// Remove Emojis (Surrogate pairs and common symbols)
|
||||
string safeMsg = System.Text.RegularExpressions.Regex.Replace(e.Message, @"\p{Cs}|[✅❌🌐📈🔴🧪ℹ️🚨🏆💰⬇️⬆️🔹🔸✨🔥📊📝🔄⏸️]", "");
|
||||
safeMsg = safeMsg.Replace("\r\n", " | ").Replace("\n", " | ").Replace(" ", " ").Trim();
|
||||
|
||||
string logLine = $"[{e.Timestamp:HH:mm:ss}] {safeMsg}{Environment.NewLine}";
|
||||
|
||||
await File.AppendAllTextAsync(fullPath, logLine);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignored to prevent cascading lockups
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Log(string message, LogLevel level = LogLevel.Info)
|
||||
{
|
||||
var e = new LogMessageEventArgs(message, level);
|
||||
lock (_lock)
|
||||
{
|
||||
_history.Add(e);
|
||||
// Optimize list pruning to avoid heavy O(N) operations per log
|
||||
if (_history.Count > 10500)
|
||||
{
|
||||
// Remove older items efficiently in a batch
|
||||
int itemsToRemove = _history.Count - 9000;
|
||||
_history.RemoveRange(0, itemsToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
OnLogMessage?.Invoke(this, e);
|
||||
}
|
||||
catch { }
|
||||
|
||||
// Standard Console output as fallback/debug
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [{level}] {message}");
|
||||
|
||||
_logChannel.Writer.TryWrite(e);
|
||||
}
|
||||
|
||||
public void Info(string message) => Log(message, LogLevel.Info);
|
||||
public void Debug(string message) => Log(message, LogLevel.Debug);
|
||||
public void Warning(string message) => Log(message, LogLevel.Warning);
|
||||
public void Error(string message) => Log(message, LogLevel.Error);
|
||||
public void Trade(string message) => Log(message, LogLevel.Trade);
|
||||
public void TradeReasoning(string message) => Log(message, LogLevel.TradeReasoning);
|
||||
|
||||
public List<LogMessageEventArgs> GetHistory(TimeSpan maxAge)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var cutoff = DateTime.Now - maxAge;
|
||||
return _history.Where(x => x.Timestamp >= cutoff).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using PolyTraderSharp.Models;
|
||||
using IcgSoftware.Threema.CoreMsgApi;
|
||||
using IcgSoftware.Threema.CoreMsgApi.Exceptions;
|
||||
|
||||
namespace PolyTraderSharp.Services
|
||||
{
|
||||
public class ThreemaService : BackgroundService
|
||||
{
|
||||
public event Action<string>? OnCommandReceived;
|
||||
|
||||
private readonly TerminalLogger _logger;
|
||||
private ServerSettings _settings;
|
||||
private readonly string _settingsPath = "server_settings.xml";
|
||||
|
||||
private readonly JobStatusRow _jobStatus;
|
||||
private HttpListener? _httpListener;
|
||||
private APIConnector? _apiConnector;
|
||||
|
||||
public ThreemaService(TerminalLogger logger, JobManager jobManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_settings = ServerSettings.Load(_settingsPath);
|
||||
|
||||
_jobStatus = new JobStatusRow
|
||||
{
|
||||
JobName = "Threema Webhook Listener",
|
||||
Description = "Listens for incoming Threema Gateway Webhooks on the configured port.",
|
||||
StatusText = "Pending Initial Delay..."
|
||||
};
|
||||
|
||||
_jobStatus.ManualTriggerAction = async () =>
|
||||
{
|
||||
_jobStatus.StatusText = "Manual trigger not supported for Webhook";
|
||||
await Task.Delay(2000);
|
||||
};
|
||||
|
||||
jobManager.RegisterJob(_jobStatus);
|
||||
InitConnector();
|
||||
}
|
||||
|
||||
public void ReloadSettings()
|
||||
{
|
||||
_settings = ServerSettings.Load(_settingsPath);
|
||||
InitConnector();
|
||||
}
|
||||
|
||||
private void InitConnector()
|
||||
{
|
||||
if (_settings.ThreemaEnabled && !string.IsNullOrEmpty(_settings.ThreemaGatewayId) && !string.IsNullOrEmpty(_settings.ThreemaSecret))
|
||||
{
|
||||
// Initialize the pt-icg SDK APIConnector
|
||||
_apiConnector = new APIConnector(_settings.ThreemaGatewayId, _settings.ThreemaSecret, new PublicKeyStoreNone());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> SendMessageAsync(string text, string parseMode = "")
|
||||
{
|
||||
if (!_settings.ThreemaEnabled || _apiConnector == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Using the GroupID field as the target (could be a Threema ID)
|
||||
string targetId = _settings.ThreemaGroupId;
|
||||
if (string.IsNullOrEmpty(targetId))
|
||||
{
|
||||
_logger.Error("Threema send failed: Target ID (Group ID) is not configured.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
// If no private key is configured, fallback to Basic mode (SendTextMessageSimple)
|
||||
// Note: Basic mode does not support actual Group messaging, so targetId must be a personal Threema ID.
|
||||
// If a private key IS configured, we would use E2E mode, but without the official 2.0 SDK's
|
||||
// SendGroupTextMessage, we just send a direct E2E message.
|
||||
|
||||
if (string.IsNullOrEmpty(_settings.ThreemaPrivateKey))
|
||||
{
|
||||
string msgId = _apiConnector.SendTextMessageSimple(targetId, text);
|
||||
if (!string.IsNullOrEmpty(msgId))
|
||||
{
|
||||
_logger.Info($"Threema basic message sent. ID: {msgId}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// E2E Mode Direct Message
|
||||
byte[] privateKey = DataUtils.HexStringToByteArray(_settings.ThreemaPrivateKey);
|
||||
byte[] publicKey = _apiConnector.LookupKey(targetId);
|
||||
|
||||
if (publicKey == null)
|
||||
{
|
||||
_logger.Error($"Threema E2E failed: Could not lookup public key for {targetId}");
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] nonce = CryptTool.RandomNonce();
|
||||
var encryptResult = CryptTool.EncryptTextMessage(text, privateKey, publicKey);
|
||||
|
||||
if (encryptResult != null && encryptResult.Result != null)
|
||||
{
|
||||
byte[] box = encryptResult.Result;
|
||||
string msgId = _apiConnector.SendE2EMessage(targetId, encryptResult.Nonce, box);
|
||||
if (!string.IsNullOrEmpty(msgId))
|
||||
{
|
||||
_logger.Info($"Threema E2E message sent. ID: {msgId}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Threema send failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_jobStatus.StatusText = "Idle";
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (!_settings.ThreemaEnabled || !_jobStatus.IsEnabled)
|
||||
{
|
||||
_jobStatus.StatusText = "Paused / Disabled";
|
||||
if (_httpListener != null && _httpListener.IsListening)
|
||||
{
|
||||
_httpListener.Stop();
|
||||
}
|
||||
await Task.Delay(5000, stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_httpListener == null || !_httpListener.IsListening)
|
||||
{
|
||||
_httpListener = new HttpListener();
|
||||
_httpListener.Prefixes.Add($"http://*:{_settings.ThreemaWebhookPort}/");
|
||||
_httpListener.Start();
|
||||
_jobStatus.StatusText = $"Listening on port {_settings.ThreemaWebhookPort}...";
|
||||
_logger.Info($"[Threema] Webhook listener started on port {_settings.ThreemaWebhookPort}");
|
||||
}
|
||||
|
||||
_jobStatus.LastRun = DateTime.Now;
|
||||
|
||||
var getContextTask = _httpListener.GetContextAsync();
|
||||
var delayTask = Task.Delay(5000, stoppingToken);
|
||||
|
||||
var completedTask = await Task.WhenAny(getContextTask, delayTask);
|
||||
|
||||
if (completedTask == getContextTask)
|
||||
{
|
||||
var context = await getContextTask;
|
||||
_ = Task.Run(() => HandleIncomingWebhook(context), stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (TaskCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ex is HttpListenerException hle && hle.ErrorCode == 5)
|
||||
{
|
||||
_logger.Error($"[Threema] Access Denied starting Webhook. Try running as Administrator or run: netsh http add urlacl url=http://*:{_settings.ThreemaWebhookPort}/ user=Everyone");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Error($"[Threema] Listener error: {ex.Message}");
|
||||
}
|
||||
|
||||
_jobStatus.StatusText = "Error! Retrying in 5s...";
|
||||
if (_httpListener != null)
|
||||
{
|
||||
try { _httpListener.Close(); } catch { }
|
||||
_httpListener = null;
|
||||
}
|
||||
await Task.Delay(5000, stoppingToken);
|
||||
}
|
||||
|
||||
_jobStatus.NextRun = DateTime.Now;
|
||||
}
|
||||
|
||||
if (_httpListener != null)
|
||||
{
|
||||
try { _httpListener.Close(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleIncomingWebhook(HttpListenerContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = context.Request;
|
||||
var response = context.Response;
|
||||
|
||||
if (request.HttpMethod == "POST")
|
||||
{
|
||||
using (var reader = new StreamReader(request.InputStream, request.ContentEncoding))
|
||||
{
|
||||
string body = reader.ReadToEnd();
|
||||
var parsedParams = System.Web.HttpUtility.ParseQueryString(body);
|
||||
|
||||
string? from = parsedParams["from"];
|
||||
string? to = parsedParams["to"];
|
||||
string? nonceStr = parsedParams["nonce"];
|
||||
string? boxStr = parsedParams["box"];
|
||||
string? macStr = parsedParams["mac"];
|
||||
|
||||
// Decrypt the message if E2E
|
||||
if (!string.IsNullOrEmpty(from) && !string.IsNullOrEmpty(nonceStr) && !string.IsNullOrEmpty(boxStr) && !string.IsNullOrEmpty(_settings.ThreemaPrivateKey) && _apiConnector != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] privateKey = DataUtils.HexStringToByteArray(_settings.ThreemaPrivateKey);
|
||||
byte[] publicKey = _apiConnector.LookupKey(from);
|
||||
byte[] nonce = DataUtils.HexStringToByteArray(nonceStr);
|
||||
byte[] box = DataUtils.HexStringToByteArray(boxStr);
|
||||
|
||||
if (publicKey != null)
|
||||
{
|
||||
var msg = CryptTool.DecryptMessage(box, privateKey, publicKey, nonce);
|
||||
if (msg is IcgSoftware.Threema.CoreMsgApi.Messages.TextMessage textMsg)
|
||||
{
|
||||
string text = textMsg.Text;
|
||||
string logText = text.Length > 200 ? text.Substring(0, 200) + "..." : text;
|
||||
_logger.Info($"[Threema] Empfangen von {from}: {logText}");
|
||||
|
||||
if (text.StartsWith("/"))
|
||||
{
|
||||
OnCommandReceived?.Invoke(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception dex)
|
||||
{
|
||||
_logger.Warning($"[Threema] Failed to decrypt incoming message: {dex.Message}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info($"[Threema] Received webhook, but cannot process (Basic mode doesn't support incoming, or missing E2E keys).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response.StatusCode = 200;
|
||||
response.Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"[Threema] Webhook handling error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using MongoDB.Driver;
|
||||
using PolyTraderSharp.Extensions;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using PolyTraderSharp.Models;
|
||||
|
||||
namespace PolyTraderSharp.Services
|
||||
{
|
||||
public class TraderAnalyticsJob : BackgroundService
|
||||
{
|
||||
private readonly TradingState _state;
|
||||
private readonly TerminalLogger _logger;
|
||||
private readonly IMongoDatabase _db;
|
||||
private readonly JobStatusRow _jobStatus;
|
||||
|
||||
public TraderAnalyticsJob(TradingState state, TerminalLogger logger, IMongoDatabase db, JobManager jobManager)
|
||||
{
|
||||
_state = state;
|
||||
_logger = logger;
|
||||
_db = db;
|
||||
|
||||
_jobStatus = new JobStatusRow
|
||||
{
|
||||
JobName = "Trader Analytics",
|
||||
Description = "Analysiert Master-Trader-Performance pro Account (letzte 30 Trades, 7D Volumen).",
|
||||
StatusText = "Pending Initial Delay..."
|
||||
};
|
||||
|
||||
_jobStatus.ManualTriggerAction = async () =>
|
||||
{
|
||||
_jobStatus.StatusText = "Running (Manual)...";
|
||||
await RunAnalyticsAsync();
|
||||
_jobStatus.StatusText = "Idle";
|
||||
_jobStatus.LastRun = DateTime.Now;
|
||||
};
|
||||
|
||||
jobManager.RegisterJob(_jobStatus);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
// Initial wait so the application can start smoothly
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (_jobStatus.IsEnabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
_jobStatus.StatusText = "Running (Scheduled)...";
|
||||
await RunAnalyticsAsync();
|
||||
_jobStatus.LastRun = DateTime.Now;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Error in TraderAnalyticsJob: {ex.Message}");
|
||||
_jobStatus.StatusText = "Error!";
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_jobStatus.StatusText != "Error!") _jobStatus.StatusText = "Idle";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_jobStatus.StatusText = "Paused";
|
||||
}
|
||||
|
||||
_jobStatus.NextRun = DateTime.Now.AddHours(6);
|
||||
await Task.Delay(TimeSpan.FromHours(6), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private Task RunAnalyticsAsync()
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.Info("🔄 Starte Trader Analytics (7D / Letzte 30 Trades)...");
|
||||
|
||||
var closedTradesColl = _db.GetCollection<ClosedTrade>("closed_trades");
|
||||
// Ensure indexes
|
||||
closedTradesColl.EnsureIndex(x => x.AccountId);
|
||||
closedTradesColl.EnsureIndex(x => x.SourceTraderId);
|
||||
|
||||
DateTime sevenDaysAgo = DateTime.UtcNow.AddDays(-7);
|
||||
|
||||
foreach (var acc in _state.Accounts.Values)
|
||||
{
|
||||
var results = new List<TraderAnalyticsResult>();
|
||||
|
||||
// Find all master traders that this account has copied successfully in their entire history
|
||||
// Or we just find MTs that were copied in the last 7 days?
|
||||
// The requirement says: "Welche Trades ... in den letzten 7 Tagen kopiert ... und wie hoch war die Winrate der letzten 30 Trades"
|
||||
// Thus we only care about MTs that had at least 1 trade in the last 7 days!
|
||||
int accId = acc.AccountId;
|
||||
var recentMTs = closedTradesColl.LiteFind(x => x.AccountId == accId && x.ClosedAt >= sevenDaysAgo)
|
||||
.Select(x => x.SourceTraderId)
|
||||
.Distinct()
|
||||
.Where(id => id != 0) // Ignore orphaned historical trades (API resolved/auto-redeem before ID tracking patch)
|
||||
.ToList();
|
||||
|
||||
foreach (var mtId in recentMTs)
|
||||
{
|
||||
var mtInfo = _state.Traders.Values.FirstOrDefault(t => t.Id == mtId);
|
||||
string name = mtInfo?.DisplayName ?? $"MT #{mtId}";
|
||||
string address = mtInfo?.WalletAddress ?? "";
|
||||
|
||||
// 1. Trades im 7D Fenster zählen
|
||||
int trades7D = closedTradesColl.LiteFind(x => x.AccountId == accId && x.SourceTraderId == mtId && x.ClosedAt >= sevenDaysAgo).Count();
|
||||
|
||||
// 2. Letzte 30 Trades holen
|
||||
var last30 = closedTradesColl.LiteFind(x => x.AccountId == accId && x.SourceTraderId == mtId)
|
||||
.OrderByDescending(x => x.ClosedAt)
|
||||
.Take(30)
|
||||
.ToList();
|
||||
|
||||
if (last30.Count == 0) continue;
|
||||
|
||||
decimal pnl30T = last30.Sum(x => x.RealizedPnl);
|
||||
int wins = last30.Count(x => x.RealizedPnl > 0);
|
||||
// Exakt 0 ist kein Win, nur > 0
|
||||
decimal winrate = ((decimal)wins / last30.Count) * 100m;
|
||||
|
||||
results.Add(new TraderAnalyticsResult
|
||||
{
|
||||
AccountId = acc.AccountId,
|
||||
SourceTraderId = mtId,
|
||||
SourceTraderName = name,
|
||||
SourceTraderAddress = address,
|
||||
Winrate30T = winrate,
|
||||
Pnl30T = pnl30T,
|
||||
Trades7D = trades7D
|
||||
});
|
||||
}
|
||||
|
||||
// Save to cache
|
||||
_state.TraderAnalyticsCache[acc.AccountId] = results;
|
||||
}
|
||||
|
||||
_logger.Info("✅ Trader Analytics erfolgreich abgeschlossen und im Cache aktualisiert.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"TraderAnalyticsJob Exception: {ex}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,550 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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
|
||||
{
|
||||
/*
|
||||
* Verwaltet alles was mit dem Logging und der Ausgabe im Terminal zu Tun hat.
|
||||
*/
|
||||
internal class Logging
|
||||
{
|
||||
/// <summary>
|
||||
/// Die Loglevel Debug, Info und Error sollten klar sein.
|
||||
/// Das Loglevel Trade soll Informationen zu von uns platzierten oder versucht zu platzierten Trades erhalten.
|
||||
/// Das Loglevel TradeReasoning dient rein zu Analysezwecken. hier wollen wir auswerten können warum wir uns für oder gegen einen Trade entschieden haben.
|
||||
/// </summary>
|
||||
public enum LogLevel { Debug, Info, Trade,TradeReasoning, Error }
|
||||
|
||||
public void LogSchreiben( string message, LogLevel l = LogLevel.Info, bool TerminalOut = false) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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
|
||||
{ /*
|
||||
* Verwaltet alles was mit Mullvad zu tun hat. Also VPN verbindung aktivieren / deaktivieren / prüfen
|
||||
*/
|
||||
internal class Mullvad
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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