Files
PolyTraderSharp/services/TraderMonitorService.cs
T
RichardandClaude Opus 4.8 88fc982089 Phase 5.3-ClosedTrade: ClosedTrade -> Modul + ICopyTradeLogRepository
- ClosedTrade (+ ClosedTradeRow) nach src/PolyTrader.Modules.CopyTrading/Models/.
- Neues ICopyTradeLogRepository (+ Mongo-Impl) im Modul: EnsureIndexes, Exists,
  Insert, Find(predicate) auf der closed_trades-Collection.
- closed_trades-Zugriffe der Modul-Services vom App-Shim auf das Repo umgestellt:
  TraderMonitorService, PersistenceService, PolymarketWssClient, TraderAnalyticsJob.
  Shim-/DB-Usings dort entfernt.
- TraderMonitorService nutzt kein _db mehr (auch die uebrigen _db-Guards aus 3d
  entfernt, da Repos immer verfuegbar sind). Verhaltensneutral.
- frm_main (UI) + Program.cs (BsonDocument-Cleanup) bleiben auf _db.
- Build 0 Fehler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:27:22 +02:00

1313 lines
70 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using PolyTrader.Core.Persistence;
using PolyTrader.Modules.CopyTrading.Persistence;
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 CopyTradingState _copyState;
private readonly PolymarketApiService _api;
private readonly PolymarketClobClient _clob;
private readonly ChannelWriter<CopySignal> _signalWriter;
private readonly ChannelWriter<ClosedTrade> _closedTradeWriter;
private readonly TerminalLogger _logger;
private readonly ICopyTradeLogRepository _tradeLog;
private readonly IPositionRepository _positionRepo;
private readonly IMarketRepository _marketRepo;
// 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 readonly ConcurrentDictionary<string, bool> _activeWssPolls = new();
private DateTime _lastLivePoll = DateTime.MinValue;
private DateTime _lastClosedPoll = DateTime.MinValue;
private DateTime _lastMasterPositionPoll = DateTime.MinValue;
// AutoRedeem Tracker for REST updates
private static readonly ConcurrentDictionary<string, (int Count, DateTime LastAttempt)> _restRedeemAttempts = new();
public TraderMonitorService(
TradingState state,
CopyTradingState copyState,
PolymarketApiService api,
PolymarketClobClient clob,
ChannelWriter<CopySignal> signalWriter,
ChannelWriter<ClosedTrade> closedTradeWriter,
TerminalLogger logger,
IPositionRepository positionRepo,
IMarketRepository marketRepo,
ICopyTradeLogRepository tradeLog)
{
_state = state;
_copyState = copyState;
_api = api;
_clob = clob;
_signalWriter = signalWriter;
_closedTradeWriter = closedTradeWriter;
_logger = logger;
_positionRepo = positionRepo;
_marketRepo = marketRepo;
_tradeLog = tradeLog;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.Info("TraderMonitorService started background API priority polling...");
// ===== STARTUP: _processedClosures aus DB vorladen =====
// Verhindert, dass nach einem Neustart alle historischen geschlossenen Trades
// erneut als ClosedTrade-Records in die Datenbank geschrieben werden.
try
{
var allClosed = _tradeLog.Find(x => !x.IsDemo);
int preloaded = 0;
foreach (var ct in allClosed)
{
if (!string.IsNullOrEmpty(ct.TokenId))
{
string key = $"{ct.AccountId}_{ct.TokenId}";
_processedClosures.TryAdd(key, true);
preloaded++;
}
}
_logger.Info($"_processedClosures vorgeladen: {preloaded} Einträge aus closed_trades geladen (verhindert Duplikate nach Neustart).");
}
catch (Exception ex)
{
_logger.Warning($"_processedClosures Preload fehlgeschlagen: {ex.Message}");
}
// ===== STARTUP: Sofortiger Warmup der Live-Positionen und Master-Tracker =====
// Ohne diesen Warmup ist der Proportionalitätsfilter nach einem Neustart 30s lang blind,
// und die 2-Min-Karenzzeit erlaubt unberechtigte SELLs.
try
{
_logger.Info("Startup: Lade Live-Positionen und Master-Tracker-Cache...");
await PollLiveAccountsAsync(stoppingToken);
_lastLivePoll = DateTime.UtcNow;
await SyncMasterTraderPositionsAsync(stoppingToken);
_lastMasterPositionPoll = DateTime.UtcNow;
_logger.Info($"Startup: MasterTraderPositions warmup abgeschlossen ({_copyState.MasterTraderPositions.Count} Einträge).");
}
catch (Exception ex)
{
_logger.Warning($"Startup Warmup fehlgeschlagen (nicht kritisch, wird im Loop nachgeholt): {ex.Message}");
}
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;
}
// Master Trader Position Tracking (Runs every 30s, offset from live poll)
if ((DateTime.UtcNow - _lastMasterPositionPoll).TotalSeconds > 30)
{
await SyncMasterTraderPositionsAsync(stoppingToken);
_lastMasterPositionPoll = DateTime.UtcNow;
}
// Background Closed Trades Sync (Runs every 2 minutes decoupled from local state)
if ((DateTime.UtcNow - _lastClosedPoll).TotalMinutes > 2)
{
await PollClosedAccountsAsync(stoppingToken);
_lastClosedPoll = 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 = _copyState.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.");
}
ProcessActivityItemsMerged(activity, 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;
}
}
public void TriggerFastBlockchainPoll(string txHash, string rpcUrl, string walletAddress)
{
var trader = _copyState.Traders.Values.FirstOrDefault(t => t.WalletAddress.Equals(walletAddress, StringComparison.OrdinalIgnoreCase));
if (trader == null || !trader.IsActive) return;
if (_state.EnableBlockchainParser)
{
if (!_processedTxHashes.TryAdd(txHash, DateTime.UtcNow)) return; // Debounce txHash duplicate WSS events
Task.Run(async () =>
{
try
{
var signals = await _api.ParseBlockchainTransactionAsync(txHash, rpcUrl, trader.WalletAddress);
if (signals != null && signals.Count > 0)
{
foreach (var signal in signals)
{
signal.TraderId = trader.Id;
if (_state.MarketCache.TryGetValue(signal.TokenId, out var fastCachedData))
{
signal.MarketQuestion = fastCachedData.Question;
signal.MarketSlug = fastCachedData.Slug;
signal.EndDate = fastCachedData.EndDate;
if (!string.IsNullOrEmpty(fastCachedData.ClobTokenIds))
{
try {
var tokenArr = System.Text.Json.JsonSerializer.Deserialize<System.Collections.Generic.List<string>>(fastCachedData.ClobTokenIds);
if (tokenArr != null) {
int idx = tokenArr.IndexOf(signal.TokenId);
if (idx >= 0)
{
if (!string.IsNullOrEmpty(fastCachedData.Outcomes))
{
var outcomesArr = System.Text.Json.JsonSerializer.Deserialize<System.Collections.Generic.List<string>>(fastCachedData.Outcomes);
if (outcomesArr != null && idx < outcomesArr.Count)
{
signal.Outcome = outcomesArr[idx];
}
}
// Fallback, if Outcomes array is empty or index out of bounds
if (string.IsNullOrEmpty(signal.Outcome))
{
if (idx == 0) signal.Outcome = "Yes";
else if (idx == 1) signal.Outcome = "No";
else if (idx > 1) signal.Outcome = $"Out{idx}";
}
}
}
} catch {}
}
}
else
{
// One-Time Cold Hit from Gamma API (eliminating LiteDB Full-Table-Scan blockage)
var coldItem = await _api.GetMarketByTokenIdAsync(signal.TokenId);
if (coldItem != null)
{
signal.MarketQuestion = coldItem.Question;
signal.MarketSlug = coldItem.Slug;
signal.EndDate = coldItem.EndDate;
if (!string.IsNullOrEmpty(coldItem.ClobTokenIds))
{
try {
var tokenArr = System.Text.Json.JsonSerializer.Deserialize<System.Collections.Generic.List<string>>(coldItem.ClobTokenIds);
if (tokenArr != null) {
int idx = tokenArr.IndexOf(signal.TokenId);
if (idx >= 0)
{
if (!string.IsNullOrEmpty(coldItem.Outcomes))
{
var outcomesArr = System.Text.Json.JsonSerializer.Deserialize<System.Collections.Generic.List<string>>(coldItem.Outcomes);
if (outcomesArr != null && idx < outcomesArr.Count)
{
signal.Outcome = outcomesArr[idx];
}
}
// Fallback, if Outcomes array is empty or index out of bounds
if (string.IsNullOrEmpty(signal.Outcome))
{
if (idx == 0) signal.Outcome = "Yes";
else if (idx == 1) signal.Outcome = "No";
else if (idx > 1) signal.Outcome = $"Out{idx}";
}
}
}
} catch {}
}
// Update Cache for 0ms next time
_state.MarketCache[signal.TokenId] = coldItem;
// Asynchronously persist market snapshot without blocking Hot Path
_ = Task.Run(() => {
try {
_marketRepo.Upsert(coldItem);
} catch { } // Failsafe
});
}
else
{
// Provide fallback display if un-cached and API fails
signal.MarketQuestion = "Unbekannter Markt (Lädt...)";
signal.Outcome = signal.TokenId.Substring(0, 6);
}
}
bool added = _signalWriter.TryWrite(signal);
if (added)
{
string shareType = string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome;
_logger.Trade($"🚨 [QUELLE: {trader.DisplayName}] WSS\n" +
$" Markt: {signal.MarketQuestion} \n" +
$" Aktion: {signal.Side} {shareType} ({signal.Size:F2} Shares @ ${signal.Price:F3})\n" +
$" Zeit: {signal.Timestamp.ToString("HH:mm:ss")} UTC\n" +
$" TxHash: {txHash}");
}
}
return; // Fast Track Successful!
}
// If it returns null, parser failed to decode this specific proxy trace. Fall back below.
_logger.Debug($"FastTrack failed for TX {txHash}. Falling back to Data API loop.");
_processedTxHashes.TryRemove(txHash, out _); // Revert the lockout so the API can process these!
}
catch (Exception ex)
{
_logger.Error($"FastTrack Parser Error: {ex.Message}");
_processedTxHashes.TryRemove(txHash, out _); // Revert on exception too
}
// Fallback Trigger: Delegate to normal API indexer with retry loop
TriggerManualPoll(trader.WalletAddress);
});
}
else
{
TriggerManualPoll(walletAddress); // Fallback if Parser is globally disabled
}
}
/// <summary>
/// Triggered instantly by the AlchemyWebsocketService when an EVM TransferSingle is detected.
/// </summary>
public void TriggerManualPoll(string walletAddress)
{
var trader = _copyState.Traders.Values.FirstOrDefault(t => t.WalletAddress.Equals(walletAddress, StringComparison.OrdinalIgnoreCase));
if (trader != null && trader.IsActive)
{
if (!_activeWssPolls.TryAdd(trader.WalletAddress, true))
{
// Ein WSS-Poll Event läuft bereits für diesen Trader! Vermeidet 429 API-Spam bei Mikro-Trades.
return;
}
// Force an immediate poll on a separate unblocked thread, completely bypassing the 1s loop delay
Task.Run(async () =>
{
try
{
int retries = 15;
while(retries > 0)
{
int hashCountBefore = _processedTxHashes.Count;
// Increase limit to 20 for WSS polling to ensure rapid batch waves aren't truncated by the API response length,
// which had previously caused trades to be completely hidden.
var activity = await _api.GetTraderActivityAsync(trader.WalletAddress, limit: 20);
ProcessActivityItemsMerged(activity, trader);
if (_processedTxHashes.Count > hashCountBefore)
{
// Hat einen neuen Trade gefunden! Schleife beenden.
break;
}
// API DB Indexer hat WSS-Event noch nicht verarbeitet, 1s warten...
await Task.Delay(1000);
retries--;
}
}
catch (Exception ex)
{
_logger.Error($"WSS API-Poll Fehler für {trader.DisplayName}: {ex.Message}");
}
finally
{
_activeWssPolls.TryRemove(trader.WalletAddress, out _);
}
});
}
}
private async Task ExecuteRestAutoRedeemLive(AccountState acc, Position pos)
{
string redeemKey = $"{acc.AccountId}_{pos.TokenId}";
if (pos.Size < 5.0m)
{
var state = _restRedeemAttempts.GetOrAdd(redeemKey, _ => (0, DateTime.MinValue));
int newCount = state.Count + 1;
_restRedeemAttempts[redeemKey] = (newCount, DateTime.UtcNow);
if (newCount <= 1) _logger.Warning($"[REST AUTO REDEEM] Position {pos.MarketQuestion} zu klein für Limit Order (< 5 Shares). Max. 1 Retry in 5 Min.");
return;
}
_restRedeemAttempts.AddOrUpdate(redeemKey, _ => (1, DateTime.UtcNow), (_, old) => (old.Count + 1, DateTime.UtcNow));
try
{
decimal expectedFillPrice = acc.PreRedeemLimit;
decimal amountUsdc = Math.Max(pos.Size * expectedFillPrice, 0.01m);
var result = await _clob.PlaceOrderAsync(acc, pos.TokenId, "SELL", amountUsdc, expectedFillPrice, "GTC", false, false);
if (result == "OK")
{
_logger.Info($"✅ REST Auto-Redeem Sell sent for {acc.Name} at exact Limit {expectedFillPrice:F3} USD (GTC).");
}
else
{
_logger.Error($"❌ REST Auto-Redeem failed or rejected: {result}.");
}
}
catch (Exception ex)
{
_logger.Error($"REST Auto Redeem Exception: {ex.Message}");
}
}
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 PollClosedAccountsAsync(CancellationToken ct)
{
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 closedPositions = await _api.SyncClosedPositionsAsync(acc.WalletAddress, 50);
if (closedPositions == null || closedPositions.Count == 0) continue;
foreach (var cm in closedPositions)
{
string asset = cm.TryGetProperty("asset", out var ap) ? ap.GetString() ?? "" : "";
if (string.IsNullOrEmpty(asset)) continue;
// 1. Immediately extract and clear from OpenPositions cache
int resolvedSourceId = 0;
if (acc.OpenPositions.TryRemove(asset, out var removedPos))
{
resolvedSourceId = removedPos.SourceTraderId;
}
// 2. Fallback: Wenn TryRemove fehlschlägt (Race Condition mit PollLiveAccountsAsync),
// SourceTraderId aus der MongoDB open_positions-Tabelle wiederherstellen.
if (resolvedSourceId <= 0)
{
try
{
var dbPos = _positionRepo.FindLive(acc.AccountId, asset);
if (dbPos != null && dbPos.SourceTraderId > 0)
{
resolvedSourceId = dbPos.SourceTraderId;
// Auch removedPos füllen für MarketQuestion/Outcome weiter unten
if (removedPos == null) removedPos = dbPos;
}
}
catch { }
}
// 3. Fallback: Prüfe ob ein anderer Account dieselbe TokenId mit SourceTraderId hat
if (resolvedSourceId <= 0)
{
foreach (var otherAcc in _state.Accounts.Values)
{
if (otherAcc.AccountId == acc.AccountId) continue;
if (otherAcc.OpenPositions.TryGetValue(asset, out var otherPos) && otherPos.SourceTraderId > 0)
{
resolvedSourceId = otherPos.SourceTraderId;
if (removedPos == null) removedPos = otherPos;
break;
}
}
}
// 2. Prevent DB Duplicates! Fast check via Repository.
bool dbExists = _tradeLog.Exists(acc.AccountId, asset);
string duplicateKey = $"{acc.AccountId}_{asset}";
if (!dbExists && !_processedClosures.ContainsKey(duplicateKey))
{
decimal realizedPnl = 0m, entryPrice = 0m, size = 0m, exitPrice = 0m;
DateTime resolvedTimestamp = DateTime.UtcNow;
if (cm.TryGetProperty("timestamp", out var tsProp) ||
cm.TryGetProperty("updatedAt", out tsProp) ||
cm.TryGetProperty("closedAt", out tsProp) ||
cm.TryGetProperty("createdAt", out tsProp))
{
try
{
if (tsProp.ValueKind == JsonValueKind.Number)
{
long ts = tsProp.GetInt64();
if (ts > 9999999999) ts /= 1000;
resolvedTimestamp = DateTimeOffset.FromUnixTimeSeconds(ts).UtcDateTime;
}
else if (tsProp.ValueKind == JsonValueKind.String && DateTime.TryParse(tsProp.GetString(), out var parsedDate))
{
resolvedTimestamp = parsedDate.ToUniversalTime();
}
}
catch { }
}
if (cm.TryGetProperty("realizedPnl", out var rPnlProp)) realizedPnl = ParseDecimal(rPnlProp);
if (cm.TryGetProperty("avgPrice", out var epProp)) entryPrice = ParseDecimal(epProp);
if (cm.TryGetProperty("totalBought", out var szProp)) size = ParseDecimal(szProp);
// Approximate exit price based on PnL
decimal investment = size * entryPrice;
if (size > 0) exitPrice = (investment + realizedPnl) / size;
string orderKey = $"{acc.AccountId}_{asset}";
bool soldByUs = _copyState.PendingOrderTimestamps.ContainsKey(orderKey);
string exitReason = soldByUs ? "Master Trader Sold" : "Manuell Geschlossen / System";
var ctRecord = new ClosedTrade
{
TradeId = _copyState.GetNextTradeId(),
AccountId = acc.AccountId,
SourceTraderId = resolvedSourceId,
IsDemo = false,
TokenId = asset,
MarketSlug = removedPos != null ? removedPos.MarketSlug : (cm.TryGetProperty("slug", out var sp) ? sp.GetString() ?? "" : ""),
MarketQuestion = removedPos != null ? removedPos.MarketQuestion : (cm.TryGetProperty("title", out var tp) ? tp.GetString() ?? "Unknown Market" : "Unknown Market"),
Outcome = cm.TryGetProperty("outcome", out var op) ? op.GetString() ?? "" : "",
Side = "SELL",
EntryPrice = entryPrice,
ExitPrice = exitPrice,
Size = size,
RealizedPnl = realizedPnl,
PnlPercent = investment > 0 ? (realizedPnl / investment * 100m) : 0m,
OpenedAt = removedPos?.OpenedAt ?? resolvedTimestamp,
ClosedAt = resolvedTimestamp,
ExitReason = exitReason
};
_processedClosures.TryAdd(duplicateKey, true);
_closedTradeWriter.TryWrite(ctRecord);
_logger.Info($"🏆 Trade {ctRecord.MarketQuestion} synchronisiert (Hintergrund)! PnL: ${realizedPnl:F2}");
}
}
}
}
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 == null) continue; // Skip on API error
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;
decimal size = 0m, entryPrice = 0m, amountUsd = 0m, curPrice = 0m, curValue = 0m;
if (posJson.TryGetProperty("size", out var sprop)) size = ParseDecimal(sprop);
if (size < 0.001m) continue; // Exclude closed positions from API so Live Sync can process them as closed
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";
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;
try { _positionRepo.UpsertLive(acc.AccountId, existing); } catch { }
// Auto-Redeem Fallback via REST
if (acc.PreRedeemLimit > 0 && curPrice >= acc.PreRedeemLimit && acc.IsActive)
{
string redeemKey = $"{acc.AccountId}_{asset}";
bool allowAttempt = true;
if (_restRedeemAttempts.TryGetValue(redeemKey, out var state))
{
if (state.Count >= 2) allowAttempt = false;
if ((DateTime.UtcNow - state.LastAttempt).TotalMinutes < 5) allowAttempt = false;
}
if (allowAttempt)
{
if (!acc.IsDemo && _state.LiveTradingMode == TradingMode.Active)
{
_logger.Trade($"🚨 [REST AUTO REDEEM] {acc.Name} | {existing.MarketQuestion} | Preis >= {acc.PreRedeemLimit}");
_ = Task.Run(async () => await ExecuteRestAutoRedeemLive(acc, existing));
}
}
}
}
else
{
// --- Smart Master-Trader Zuordnung ---
// Versuch den Quell-Trader zu identifizieren statt pauschal "Live Sync" zu vergeben
int resolvedTraderId = 0;
string resolvedTraderName = "Live Sync";
string resolvedTraderAddress = "";
// 1. Prüfe PendingOrderTimestamps (CopyTradingEngine hat diese Order kürzlich platziert)
string orderKey = $"{acc.AccountId}_{asset}";
if (_copyState.PendingOrderTimestamps.TryGetValue(orderKey, out var pending) && pending.SourceTraderId > 0)
{
resolvedTraderId = pending.SourceTraderId;
if (_copyState.Traders.TryGetValue(resolvedTraderId, out var pendingTrader))
{
resolvedTraderName = pendingTrader.DisplayName;
resolvedTraderAddress = pendingTrader.WalletAddress;
}
}
// 2. Prüfe ob ein anderer Account dieselbe TokenId bereits mit echtem SourceTraderId hat
if (resolvedTraderId == 0)
{
foreach (var otherAcc in _state.Accounts.Values)
{
if (otherAcc.AccountId == acc.AccountId) continue;
if (otherAcc.OpenPositions.TryGetValue(asset, out var otherPos) && otherPos.SourceTraderId > 0)
{
resolvedTraderId = otherPos.SourceTraderId;
resolvedTraderName = otherPos.SourceTraderName;
resolvedTraderAddress = otherPos.SourceTraderAddress;
break;
}
}
}
// 3. Prüfe ob ein anderer Account denselben Markt (Slug+Outcome) bereits zugeordnet hat
if (resolvedTraderId == 0 && !string.IsNullOrEmpty(slug))
{
string resolvedOutcome = opp == "Yes" ? "No" : "Yes";
foreach (var otherAcc in _state.Accounts.Values)
{
if (otherAcc.AccountId == acc.AccountId) continue;
var match = otherAcc.OpenPositions.Values.FirstOrDefault(p =>
p.MarketSlug == slug && p.Outcome == resolvedOutcome && p.SourceTraderId > 0);
if (match != null)
{
resolvedTraderId = match.SourceTraderId;
resolvedTraderName = match.SourceTraderName;
resolvedTraderAddress = match.SourceTraderAddress;
break;
}
}
}
DateTime resolvedOpenedAt = DateTime.UtcNow;
// 4. Prüfe lokale DB-Tabelle für den Fall eines Programm-Neustarts / API-Syncs
if (resolvedTraderId == 0)
{
try
{
var dbPos = _positionRepo.FindLive(acc.AccountId, asset);
if (dbPos != null)
{
if (dbPos.SourceTraderId > 0)
{
resolvedTraderId = dbPos.SourceTraderId;
resolvedTraderName = dbPos.SourceTraderName;
resolvedTraderAddress = dbPos.SourceTraderAddress;
}
if (dbPos.OpenedAt > DateTime.MinValue)
{
resolvedOpenedAt = dbPos.OpenedAt;
}
}
}
catch { }
}
var newPos = new Position
{
TokenId = asset,
MarketSlug = slug,
MarketQuestion = title,
Outcome = opp == "Yes" ? "No" : "Yes",
SourceTraderId = resolvedTraderId,
SourceTraderName = resolvedTraderName,
SourceTraderAddress = resolvedTraderAddress,
Side = "BUY",
Size = size,
EntryPrice = entryPrice,
AmountUsd = amountUsd,
CurrentPrice = curPrice,
CurrentValueUsd = curValue,
ExpiryDate = expiry,
OpenedAt = resolvedOpenedAt
};
acc.OpenPositions.TryAdd(asset, newPos);
try { _positionRepo.UpsertLive(acc.AccountId, newPos); } catch { }
if (resolvedTraderId > 0)
_logger.Info($"🌐 Live Position erkannt: {title} ({newPos.Outcome}) - ${amountUsd} - Account: {acc.Name} [Zugeordnet: {resolvedTraderName}]");
else
_logger.Info($"🌐 Live Position erkannt: {title} ({newPos.Outcome}) - ${amountUsd} - Account: {acc.Name} [Kein Master-Trader zugeordnet]");
}
}
var tokensToInvestigate = acc.OpenPositions
.Where(kvp => !currentTokens.Contains(kvp.Key))
.Where(kvp => (DateTime.UtcNow - kvp.Value.OpenedAt).TotalMinutes > 5)
.Select(kvp => kvp.Key)
.ToList();
if (tokensToInvestigate.Count > 0)
{
var closedPositions = await _api.SyncClosedPositionsAsync(acc.WalletAddress, 50);
foreach (var k in tokensToInvestigate)
{
if (acc.OpenPositions.TryGetValue(k, out var removedPos))
{
// 2. Fallback: Auch im Live Sync: Wenn SourceTraderId verloren ging,
// aus der MongoDB open_positions-Tabelle wiederherstellen.
if (removedPos.SourceTraderId <= 0)
{
try
{
var dbPos = _positionRepo.FindLive(acc.AccountId, k);
if (dbPos != null && dbPos.SourceTraderId > 0)
{
removedPos.SourceTraderId = dbPos.SourceTraderId;
}
}
catch { }
}
JsonElement? matchedClose = null;
foreach (var cm in closedPositions)
{
if (cm.TryGetProperty("asset", out var ap) && ap.GetString() == k)
{
matchedClose = cm;
break;
}
}
if (matchedClose.HasValue)
{
acc.OpenPositions.TryRemove(k, out _); // Safe removal!
try { _positionRepo.DeleteLive(acc.AccountId, k); } catch { }
decimal realizedPnl = 0m;
DateTime resolvedClosedAt = DateTime.UtcNow;
if (matchedClose.Value.TryGetProperty("timestamp", out var tsProp) ||
matchedClose.Value.TryGetProperty("updatedAt", out tsProp) ||
matchedClose.Value.TryGetProperty("closedAt", out tsProp) ||
matchedClose.Value.TryGetProperty("createdAt", out tsProp))
{
try
{
if (tsProp.ValueKind == JsonValueKind.Number)
{
long ts = tsProp.GetInt64();
if (ts > 9999999999) ts /= 1000;
resolvedClosedAt = DateTimeOffset.FromUnixTimeSeconds(ts).UtcDateTime;
}
else if (tsProp.ValueKind == JsonValueKind.String && DateTime.TryParse(tsProp.GetString(), out var parsedDate))
{
resolvedClosedAt = parsedDate.ToUniversalTime();
}
}
catch { }
}
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}");
string orderKey = $"{acc.AccountId}_{removedPos.TokenId}";
bool soldByUs = _copyState.PendingOrderTimestamps.ContainsKey(orderKey);
string exitReason = soldByUs ? "Master Trader Sold" : "Market Resolved";
var ctRecord = new ClosedTrade
{
TradeId = _copyState.GetNextTradeId(),
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 = resolvedClosedAt,
ExitReason = exitReason
};
if (soldByUs) _copyState.PendingOrderTimestamps.TryRemove(orderKey, out _);
_processedClosures.TryAdd(duplicateKey, true);
_closedTradeWriter.TryWrite(ctRecord);
}
}
else
{
var (isClosed, isWinner) = await _api.CheckMarketResolutionAsync(removedPos.MarketSlug, removedPos.TokenId);
if (isClosed)
{
acc.OpenPositions.TryRemove(k, out _); // Safe removal!
try { _positionRepo.DeleteLive(acc.AccountId, k); } catch { }
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}");
string orderKey = $"{acc.AccountId}_{removedPos.TokenId}";
bool soldByUs = _copyState.PendingOrderTimestamps.ContainsKey(orderKey);
string exitReason = soldByUs ? "Master Trader Sold" : "Market Resolved";
var ctRecord = new ClosedTrade
{
TradeId = _copyState.GetNextTradeId(),
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 = exitReason
};
if (soldByUs) _copyState.PendingOrderTimestamps.TryRemove(orderKey, out _);
_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
{
var ageMinutes = (DateTime.UtcNow - removedPos.OpenedAt).TotalMinutes;
if (ageMinutes >= 60)
{
if (acc.OpenPositions.TryRemove(k, out _))
{
try { _positionRepo.DeleteLive(acc.AccountId, k); } catch { }
_logger.Info($"🌐 Live Position {removedPos.MarketQuestion} final entfernt (Ext. Verkauft/Wartend nach {ageMinutes:F0} Min.)");
}
}
}
}
}
}
}
await Task.Delay(500, ct);
}
}
/// <summary>
/// Periodically syncs how many shares each master trader holds for tokens we've copied.
/// This data is used by CopyTradingEngine to determine if a SELL signal is a partial sell (ignore) or a full exit (copy).
/// </summary>
private async Task SyncMasterTraderPositionsAsync(CancellationToken ct)
{
try
{
// Step 1: Collect all (TraderId -> Set<TokenId>) from our open positions across all accounts
var traderTokenMap = new Dictionary<int, HashSet<string>>();
foreach (var acc in _state.Accounts.Values.Where(a => a.IsActive))
{
foreach (var pos in acc.OpenPositions.Values)
{
if (pos.SourceTraderId <= 0 || string.IsNullOrEmpty(pos.TokenId)) continue;
if (!traderTokenMap.TryGetValue(pos.SourceTraderId, out var tokens))
{
tokens = new HashSet<string>();
traderTokenMap[pos.SourceTraderId] = tokens;
}
tokens.Add(pos.TokenId);
}
}
if (traderTokenMap.Count == 0) return;
// Step 2: For each trader, fetch their current positions and update the cache
foreach (var (traderId, tokenIds) in traderTokenMap)
{
if (ct.IsCancellationRequested) break;
if (!_copyState.Traders.TryGetValue(traderId, out var trader) || string.IsNullOrEmpty(trader.WalletAddress))
continue;
var positionSizes = await _api.GetTraderPositionSizesAsync(trader.WalletAddress, tokenIds);
// Update cache for all tokens this trader is supposed to hold
foreach (var tokenId in tokenIds)
{
string key = $"{traderId}_{tokenId}";
decimal shares = positionSizes.ContainsKey(tokenId) ? positionSizes[tokenId] : 0m;
_copyState.MasterTraderPositions[key] = (shares, DateTime.UtcNow);
}
await Task.Delay(200, ct); // Brief delay between traders to avoid rate limits
}
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_logger.Error($"SyncMasterTraderPositions Error: {ex.Message}");
}
}
private async Task CleanupStaleOpenOrdersAsync(CancellationToken ct)
{
var keysToProcess = _copyState.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 (_copyState.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)
_copyState.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 ProcessActivityItemsMerged(List<JsonElement> activity, TrackedTrader trader)
{
try
{
// Group fragmented trades by txHash + asset + side so dust-fills do not block major fills
var validItems = activity.Where(act => {
string type = act.GetProperty("type").GetString()?.ToUpper() ?? "";
return type == "TRADE" || type == "BUY" || type == "SELL";
}).ToList();
var grouped = validItems.GroupBy(act => {
string tx = act.GetProperty("transactionHash").GetString() ?? "";
string sideStr = act.GetProperty("type").GetString() ?? "";
if (act.TryGetProperty("side", out var sProp) && sProp.ValueKind == JsonValueKind.String) sideStr = sProp.GetString() ?? sideStr;
else if (act.TryGetProperty("action", out var acProp) && acProp.ValueKind == JsonValueKind.String) sideStr = acProp.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 ap) && ap.ValueKind == JsonValueKind.String) asset = ap.GetString() ?? "";
if (string.IsNullOrEmpty(asset) && act.TryGetProperty("tokenId", out var tidProp) && tidProp.ValueKind == JsonValueKind.String) asset = tidProp.GetString() ?? "";
string parsedSide = sideStr.ToUpper().Contains("SELL") ? "SELL" : "BUY";
return $"{tx}_{asset}_{parsedSide}";
}).ToList();
foreach (var group in grouped)
{
string uniqueTradeKey = group.Key;
if (string.IsNullOrEmpty(uniqueTradeKey) || uniqueTradeKey.StartsWith("_")) continue;
var parts = uniqueTradeKey.Split('_');
if (parts.Length < 3) continue;
string txHash = parts[0];
string asset = parts[1];
string parsedSide = parts.Last(); // "BUY" or "SELL"
var elements = group.ToList();
var firstAct = elements.First(); // we take metadata like timestamps/titles from the first item
// Accumulate size and define weighted price
decimal totalSize = 0m;
decimal weightedPriceSum = 0m;
foreach (var act in elements)
{
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);
}
totalSize += size;
weightedPriceSum += (price * size);
}
if (totalSize <= 0m) continue;
decimal avgPrice = weightedPriceSum / totalSize;
// Parse timestamp to prevent old trades
DateTime tradeTs = DateTime.UtcNow;
if (firstAct.TryGetProperty("timestamp", out var tsProp))
{
if (tsProp.ValueKind == JsonValueKind.Number) // Unix
{
long rawTs = tsProp.GetInt64();
if (rawTs > 1000000000000)
tradeTs = DateTimeOffset.FromUnixTimeMilliseconds(rawTs).UtcDateTime;
else
tradeTs = DateTimeOffset.FromUnixTimeSeconds(rawTs).UtcDateTime;
}
else if (tsProp.ValueKind == JsonValueKind.String && DateTime.TryParse(tsProp.GetString(), null, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var dt))
tradeTs = dt;
}
// If trade is older than 120 seconds or has an impossible future date (clock drift / timezone bug), skip
double ageSeconds = (DateTime.UtcNow - tradeTs).TotalSeconds;
if (ageSeconds > 120 || ageSeconds < -120)
{
if (ageSeconds < 86400)
{
if (_state.DebugPollingLog) _logger.Debug($"Activity skipped due to age ({ageSeconds}s / Date: {tradeTs:O}): {txHash}");
}
continue;
}
// CRITICAL: Check if the bare txHash was already processed by the Fast-Track parser.
// Fast-Track stores just "txHash", but here we use "txHash_asset_side".
// Without this check, a SELL processed by Fast-Track would be re-ingested as a BUY
// by the API (since the API sees both sides of the orderbook) — causing phantom purchases!
if (_processedTxHashes.ContainsKey(txHash))
continue; // Already handled by Fast-Track blockchain parser
// Only place the hash lock AFTER all filtering is successful (preventing dust fragments locking out main batches)!
if (!_processedTxHashes.TryAdd(uniqueTradeKey, DateTime.UtcNow))
continue; // Duplicate trade or redundant polling request
if (avgPrice <= 0.005m || totalSize <= 1.0m) continue; // Prevent absolute dust trades spanning
if (avgPrice > 0.99m) continue;
string displayQuestion = "Unknown Market";
if (firstAct.TryGetProperty("title", out var titleProp)) displayQuestion = titleProp.GetString() ?? "Unknown Market";
if (string.IsNullOrEmpty(displayQuestion) || displayQuestion == "Unknown Market")
{
// Fallback title evaluation
if (firstAct.TryGetProperty("marketQuestion", out var mqProp)) displayQuestion = mqProp.GetString() ?? "Unknown Market";
}
var signal = new CopySignal
{
TraderId = trader.Id,
TokenId = asset,
ConditionId = "",
MarketSlug = firstAct.TryGetProperty("slug", out var sp) ? sp.GetString() ?? "" : (firstAct.TryGetProperty("marketSlug", out var msp) ? msp.GetString() ?? "" : ""),
Side = parsedSide,
Price = avgPrice,
Size = totalSize,
Timestamp = tradeTs,
MarketQuestion = displayQuestion,
Outcome = firstAct.TryGetProperty("outcome", out var outProp) ? outProp.GetString() ?? "" : "",
Reason = parsedSide.ToUpper().Contains("SELL") ? "Master Trader Sold" : ""
};
// Parse endDate from activity JSON for market expiry
if (firstAct.TryGetProperty("endDate", out var endDateProp))
{
if (endDateProp.ValueKind == JsonValueKind.String && DateTime.TryParse(endDateProp.GetString(), null, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var endDt))
signal.EndDate = endDt;
else if (endDateProp.ValueKind == JsonValueKind.Number)
signal.EndDate = DateTimeOffset.FromUnixTimeSeconds(endDateProp.GetInt64()).UtcDateTime;
}
else if (firstAct.TryGetProperty("end_date_iso", out var endIso) && endIso.ValueKind == JsonValueKind.String)
{
if (DateTime.TryParse(endIso.GetString(), null, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var endDt2))
signal.EndDate = endDt2;
}
string shareType = string.IsNullOrEmpty(signal.Outcome) ? signal.Side : signal.Outcome;
bool wAdded = _signalWriter.TryWrite(signal);
if (wAdded)
{
_logger.Trade($"🚨 [QUELLE: {trader.DisplayName}] API\n" +
$" Markt: {signal.MarketQuestion}\n" +
$" Aktion: {signal.Side} {shareType} ({signal.Size:F2} Shares @ ${signal.Price:F3})\n" +
$" Zeit: {signal.Timestamp:HH:mm:ss} UTC");
}
}
}
catch (Exception ex)
{
_logger.Warning($"Fehler beim Parsen einer Activity JSON (Merged): {ex.Message}");
}
}
}
}