Phase 5: CopyTrading-Modul-Migration (Services + Modul-Contract)

- TraderMonitorService, CopyTradingEngine, TraderAnalyticsJob physisch ins
  Modul-Projekt (src/PolyTrader.Modules.CopyTrading/Services) verschoben.
- Neu: CopyTradingModule : IPolyTraderModule — registriert CopyTradingState,
  die Signal-/Trade-Channels, ICopyTradeLogRepository und die Modul-Services selbst.
- Program.cs: Modul-Discovery (foreach module.RegisterServices / RegisterUi);
  die entsprechenden Direkt-Registrierungen entfernt. StartupHydration laeuft
  weiterhin als erster HostedService (State-Hydration vor Trading).
- MasterTraderAnalyticsJob bleibt vorerst in der App (nutzt noch den App-Shim
  fuer mt_history -> braucht erst ein History-Repo). RegisterUi noch leer
  (Modul-Views folgen; Copytrading-UI weiter ueber Legacy erreichbar).
- Build 0 Fehler. Launcher zeigt jetzt "Module: 1".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-02 12:24:10 +02:00
co-authored by Claude Opus 4.8
parent 4f6c7fdb86
commit ff1db2aea0
5 changed files with 77 additions and 16 deletions
-756
View File
@@ -1,756 +0,0 @@
using System;
using MongoDB.Driver;
using PolyTrader.Core.Persistence;
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 CopyTradingState _copyState;
private readonly ChannelReader<CopySignal> _signalReader;
private readonly ChannelWriter<ClosedTrade> _closedTradeWriter;
private readonly TerminalLogger _logger;
private readonly PolymarketClobClient _clob;
private readonly PolymarketApiService _api;
private readonly IPositionRepository _positionRepo;
private readonly IMarketRepository _marketRepo;
private readonly IAccountRepository _accountRepo;
private readonly ConcurrentDictionary<int, SemaphoreSlim> _accountSemaphores = new();
private readonly ConcurrentDictionary<int, DateTime> _lastInactiveLogPerTrader = new();
public CopyTradingEngine(
TradingState state,
CopyTradingState copyState,
ChannelReader<CopySignal> signalReader,
ChannelWriter<ClosedTrade> closedTradeWriter,
TerminalLogger logger,
PolymarketClobClient clob,
PolymarketApiService api,
IPositionRepository positionRepo,
IMarketRepository marketRepo,
IAccountRepository accountRepo)
{
_state = state;
_copyState = copyState;
_signalReader = signalReader;
_closedTradeWriter = closedTradeWriter;
_logger = logger;
_clob = clob;
_api = api;
_positionRepo = positionRepo;
_marketRepo = marketRepo;
_accountRepo = accountRepo;
}
public override async Task StartAsync(CancellationToken cancellationToken)
{
_logger.Info("Starte Preload des MarketCache aus MongoDB um Flaschenhälse zu vermeiden...");
{
// Initialize cache for EVERYTHING in DB that is not closed!
var activeMarkets = _marketRepo.GetActive();
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
{
try
{
var marketData = _marketRepo.FindByTokenId(signal.TokenId);
if (marketData == null)
{
var fetchedMarket = await _api.GetMarketByTokenIdAsync(signal.TokenId);
if (fetchedMarket != null) { _marketRepo.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) {
_marketRepo.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 (!_copyState.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 (_copyState.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 (_copyState.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)
};
_copyState.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;
});
_positionRepo.UpsertDemo(account.AccountId, finalPos);
account.UpdateBalance(account.AvailableBalance - exactUsdc);
_accountRepo.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)
};
_copyState.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);
_accountRepo.Upsert(account);
if (account.OpenPositions.TryGetValue(signal.TokenId, out var savedPos))
{
_positionRepo.UpsertLive(account.AccountId, savedPos);
}
// Track order placement time for stale order cleanup
string orderKey = $"{account.AccountId}_{signal.TokenId}";
_copyState.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}";
_copyState.MasterTraderPositions.TryAdd(masterKey, (signal.Size, DateTime.UtcNow));
}
}
}
// If SELL -> Divest
else if (signal.Side == "SELL")
{
string orderKey = $"{account.AccountId}_{signal.TokenId}";
if (_copyState.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)
{
_positionRepo.DeleteDemo(account.AccountId, signal.TokenId);
decimal exitUsd = openPos.Size * signal.Price;
decimal realizedPnl = exitUsd - openPos.AmountUsd;
_state.GlobalPnl += realizedPnl;
account.UpdateBalance(account.AvailableBalance + exitUsd);
_accountRepo.Upsert(account);
var ct = new ClosedTrade
{
TradeId = _copyState.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
_copyState.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
_copyState.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;
}
}
}
-154
View File
@@ -1,154 +0,0 @@
using System;
using PolyTrader.Modules.CopyTrading.Persistence;
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 CopyTradingState _copyState;
private readonly TerminalLogger _logger;
private readonly ICopyTradeLogRepository _tradeLog;
private readonly JobStatusRow _jobStatus;
public TraderAnalyticsJob(TradingState state, CopyTradingState copyState, TerminalLogger logger, ICopyTradeLogRepository tradeLog, JobManager jobManager)
{
_state = state;
_copyState = copyState;
_logger = logger;
_tradeLog = tradeLog;
_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)...");
_tradeLog.EnsureIndexes();
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 = _tradeLog.Find(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 = _copyState.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 = _tradeLog.Find(x => x.AccountId == accId && x.SourceTraderId == mtId && x.ClosedAt >= sevenDaysAgo).Count();
// 2. Letzte 30 Trades holen
var last30 = _tradeLog.Find(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
_copyState.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