Feinschliff: restliche Copytrading-Services ins Modul + EF-Logging gedrosselt

- MasterTraderAnalyticsJob + PersistenceService von services/ nach
  src/PolyTrader.Modules.CopyTrading/Services/ verschoben und in
  CopyTradingModule.RegisterServices registriert (reiner Move). Beide sind
  copytrading-spezifisch (Master-Trader-Historie bzw. Persistenz der
  geschlossenen Copytrades ueber den Modul-Channel; Dual-Write in Core-Log bleibt).
- Program.cs: die zwei Hosted-Service-Registrierungen entfernt.
- appsettings.json: Logging-Sektion ergaenzt -> EF-Core-Command-Logging auf
  Warning (kein SQL-Spam mehr; --smoke-ui zeigt 0 "Executed DbCommand"-Zeilen).

Damit liegt die gesamte Copytrading-Logik im Modul; App/Core enthalten nur noch
generische Infrastruktur + Startup-Hydration + CLI-Tools.

Build gruen, 48 Tests gruen, --smoke-ui gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-06 19:47:15 +02:00
co-authored by Claude Opus 4.8
parent 3456fd9768
commit b8d2c8094b
5 changed files with 11 additions and 2 deletions
-197
View File
@@ -1,197 +0,0 @@
using System;
using PolyTrader.Modules.CopyTrading.Persistence;
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 CopyTradingState _copyState;
private readonly TerminalLogger _logger;
private readonly IMasterTraderHistoryRepository _historyRepo;
private readonly ITrackedTraderRepository _traderRepo;
private readonly JobStatusRow _jobStatus;
private readonly PolymarketApiService _api;
public MasterTraderAnalyticsJob(TradingState state, CopyTradingState copyState, TerminalLogger logger, IMasterTraderHistoryRepository historyRepo, ITrackedTraderRepository traderRepo, JobManager jobManager, PolymarketApiService api)
{
_state = state;
_copyState = copyState;
_logger = logger;
_historyRepo = historyRepo;
_traderRepo = traderRepo;
_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...");
_historyRepo.EnsureIndexes();
DateTime cutoff7Days = DateTime.UtcNow.AddDays(-7);
var tradersToAnalyze = _copyState.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 = _historyRepo.Exists(trader.Id, tokenId, windowStart, windowEnd);
if (!exists)
{
var record = new MasterTraderHistoryRecord
{
TraderId = trader.Id,
TokenId = tokenId,
ClosedAt = closedTs,
RealizedPnl = pnl
};
_historyRepo.Insert(record);
inserted++;
}
}
// Sleep to respect 10/s limits or general rate limits
await Task.Delay(200);
// 2. Calculate Stats from DB
var last7DaysTrades = _historyRepo.GetByTraderSince(trader.Id, cutoff7Days);
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
_traderRepo.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}");
}
}
}
}
-116
View File
@@ -1,116 +0,0 @@
using System.Threading.Channels;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PolyTrader.Core.Persistence;
using PolyTrader.Modules.CopyTrading.Persistence;
using PolyTraderSharp.Models;
namespace PolyTraderSharp.Services
{
public class PersistenceService : BackgroundService
{
private readonly ChannelReader<ClosedTrade> _tradeReader;
private readonly ICopyTradeLogRepository _tradeLog;
private readonly ITradeLogRepository _coreTradeLog;
private readonly TerminalLogger _logger;
private readonly JobStatusRow _jobStatus;
public PersistenceService(ChannelReader<ClosedTrade> tradeReader, ICopyTradeLogRepository tradeLog, ITradeLogRepository coreTradeLog, TerminalLogger logger, JobManager jobManager)
{
_tradeReader = tradeReader;
_tradeLog = tradeLog;
_coreTradeLog = coreTradeLog;
_logger = logger;
_jobStatus = new JobStatusRow
{
JobName = "MySQL 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)
_tradeLog.EnsureIndexes();
_coreTradeLog.EnsureIndexes();
// 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))
{
if (_tradeLog.Exists(trade.AccountId, trade.TokenId))
{
_logger.Debug($"Duplikat ignoriert: ClosedTrade für Account {trade.AccountId} + Token {trade.TokenId.Substring(0, Math.Min(10, trade.TokenId.Length))}... existiert bereits.");
continue;
}
}
_tradeLog.Insert(trade);
// Dual-Write: generischer, modulübergreifender Core-Trade-Log (fürs Dashboard).
_coreTradeLog.Insert(new TradeRecord
{
ModuleName = "CopyTrading",
AccountId = trade.AccountId,
IsDemo = trade.IsDemo,
TokenId = trade.TokenId,
MarketQuestion = trade.MarketQuestion,
Outcome = trade.Outcome,
Side = trade.Side,
EntryPrice = trade.EntryPrice,
ExitPrice = trade.ExitPrice,
Size = trade.Size,
RealizedPnl = trade.RealizedPnl,
PnlPercent = trade.PnlPercent,
OpenedAt = trade.OpenedAt,
ClosedAt = trade.ClosedAt,
ExitReason = trade.ExitReason
});
_logger.Debug($"Saved ClosedTrade {trade.TradeId} to MySQL");
_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)...";
}
}
}
}
}