using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FinlyticAnalyzer.Util; using FinlyticCore.Dtos; using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Models.Analyzer; using FinlyticCore.Models.Trades; using FinlyticCore.Util; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace FinlyticAnalyzer.Services; public class ActiveTradeMonitorWorker : BackgroundService { private readonly ILogger _logger; private readonly IServiceScopeFactory _scopeFactory; private readonly AnalyzerMqttClient _mqttClient; public ActiveTradeMonitorWorker(ILogger logger, IServiceScopeFactory scopeFactory, AnalyzerMqttClient mqttClient) { _logger = logger; _scopeFactory = scopeFactory; _mqttClient = mqttClient; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("[{Channel}] ActiveTradeMonitorWorker started.", "AnalyzerChannel"); try { await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); } catch (OperationCanceledException) { return; } while (!stoppingToken.IsCancellationRequested) { try { await MonitorActiveTradesAsync(stoppingToken); } catch (Exception ex) when (!stoppingToken.IsCancellationRequested) { _logger.LogError(ex, "[{Channel}] Error in ActiveTradeMonitorWorker loop.", "AnalyzerChannel"); } try { await Task.Delay(TimeSpan.FromMinutes(60), stoppingToken); } catch (OperationCanceledException) { break; } } _logger.LogInformation("[{Channel}] ActiveTradeMonitorWorker stopped.", "AnalyzerChannel"); } private async Task MonitorActiveTradesAsync(CancellationToken cancellationToken) { if (!_mqttClient.IsConnected) { _logger.LogWarning("[{Channel}] Skipping trade monitoring. RPC client not connected.", "AnalyzerChannel"); return; } // Fetch active trades var activeTrades = await _mqttClient.SendRpcRequestAsync, GetTradesRequest>( "trades_Get", new GetTradesRequest(null, "Active"), TimeSpan.FromSeconds(10)); // Fetch proposed global trades var proposedTrades = await _mqttClient.SendRpcRequestAsync, GetTradesRequest>( "trades_Get", new GetTradesRequest(null, "Proposed"), TimeSpan.FromSeconds(10)); var trades = new List(); if (activeTrades != null) trades.AddRange(activeTrades); if (proposedTrades != null) trades.AddRange(proposedTrades.Where(t => t.IsGlobalProposal)); if (trades.Count == 0) { _logger.LogInformation("[{Channel}] No active or proposed global trades found to monitor.", "AnalyzerChannel"); return; } _logger.LogInformation("[{Channel}] Found {Count} trades to monitor. Starting evaluation...", "AnalyzerChannel", trades.Count); using var scope = _scopeFactory.CreateScope(); var n8nService = scope.ServiceProvider.GetRequiredService(); var vixService = scope.ServiceProvider.GetRequiredService(); foreach (var trade in trades) { if (cancellationToken.IsCancellationRequested) break; try { await ProcessTradeAsync(trade, n8nService, vixService, cancellationToken); } catch (Exception ex) { _logger.LogError(ex, "[{Channel}] Failed to monitor trade {TradeId} ({Symbol}).", "AnalyzerChannel", trade.TradeId, trade.Symbol); } } } private async Task ProcessTradeAsync(TradeProposalDto trade, IN8nEvaluationService n8nService, IVixTrackerService vixService, CancellationToken cancellationToken) { // 1. Get Live Price var livePriceReq = new IsinRequest(trade.Isin); var livePriceDto = await _mqttClient.SendRpcRequestAsync( "tr_GetLivePrice", livePriceReq, TimeSpan.FromSeconds(3)); decimal currentPrice = livePriceDto?.CurrentPrice > 0 ? livePriceDto.CurrentPrice : trade.EntryPrice; // 2. Evaluate Hard Stops (StopLoss / TakeProfit / TimeStop) bool isLong = string.Equals(trade.SignalType, "BUY", StringComparison.OrdinalIgnoreCase) || string.Equals(trade.SignalType, "LONG", StringComparison.OrdinalIgnoreCase); // Time-Stop Evaluierung int maxHoldingDays = EstimateMaxHoldingDays(trade.Timeframe); double daysOpen = (DateTime.UtcNow - trade.CreatedAt).TotalDays; // 50% Grace Period. Bei z.B. 10 Tagen max. Haltedauer wird nach 15 Tagen ohne Zielerreichung glattgestellt. if (daysOpen > (maxHoldingDays * 1.5)) { await SendUpdateAsync(trade, currentPrice, "Close", $"Time-Stop getriggert: Setup ist invalidiert. Der Trade bewegt sich zu lange seitwärts (Offen seit {(int)daysOpen} Tagen, anvisiert waren max. {maxHoldingDays} Tage)."); return; } if (isLong) { if (trade.StopLoss > 0 && currentPrice <= trade.StopLoss) { await SendUpdateAsync(trade, currentPrice, "Close", "Hard Stop-Loss getriggert."); return; } if (trade.TakeProfit > 0 && currentPrice >= trade.TakeProfit) { await SendUpdateAsync(trade, currentPrice, "Close", "Hard Take-Profit erreicht."); return; } } else { if (trade.StopLoss > 0 && currentPrice >= trade.StopLoss) { await SendUpdateAsync(trade, currentPrice, "Close", "Hard Stop-Loss getriggert."); return; } if (trade.TakeProfit > 0 && currentPrice <= trade.TakeProfit) { await SendUpdateAsync(trade, currentPrice, "Close", "Hard Take-Profit erreicht."); return; } } // 3. Run AI evaluation for soft/dynamic updates var taResult = await _mqttClient.SendRpcRequestAsync( "ta_GetAnalysis", livePriceReq, TimeSpan.FromSeconds(5)); var latestIndicator = taResult?.Indicators?.LastOrDefault(); var taInfo = new TechnicalContextInfo { Rsi = latestIndicator?.Rsi14?.ToString("F1") ?? "N/A", SupertrendStatus = latestIndicator?.SupertrendDirection ?? "N/A", Atr = latestIndicator?.Atr14?.ToString("F2") ?? "N/A", Sma50 = (double?)latestIndicator?.Sma50, Sma200 = (double?)latestIndicator?.Sma200, DetectedPatterns = taResult?.Patterns?.Select(p => new PatternContextInfo { PatternName = p.Type, BreakoutDirection = p.BreakoutSignal?.Direction, TargetPrice = (double?)p.BreakoutSignal?.TargetPrice, PotentialPercent = (double?)p.BreakoutSignal?.PotentialPercent }).ToList() ?? new List() }; var n8nReq = new N8nAnalysisRequestDto { RequestId = Guid.NewGuid().ToString("N"), Timestamp = DateTime.UtcNow, TriggerType = "HourlyMonitor", TargetAsset = new TargetAssetInfo { Symbol = trade.Symbol, Isin = trade.Isin, Sector = trade.Sector }, MarketContext = new MarketContextInfo { Vix = vixService.GetCurrentVix(), MarketRegime = vixService.GetCurrentRegime().ToString() }, UserPreferences = new UserPreferencesInfo { InstrumentType = trade.InstrumentType, TimeframeFormatted = trade.Timeframe }, TechnicalContext = taInfo }; var aiResponse = await n8nService.EvaluateAssetAsync(n8nReq, cancellationToken); if (aiResponse == null) { _logger.LogWarning("[{Channel}] AI evaluation returned null for {TradeId}. Skipping update.", "AnalyzerChannel", trade.TradeId); return; } string newRecommendation = "Hold"; string reasoning = aiResponse.AiReasoning; decimal? newStopLoss = trade.StopLoss; decimal? newTakeProfit = trade.TakeProfit; // Check for trend reversal bool aiSuggestsShort = string.Equals(aiResponse.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) || string.Equals(aiResponse.SuggestedDirection, "Sell", StringComparison.OrdinalIgnoreCase); bool aiSuggestsLong = string.Equals(aiResponse.SuggestedDirection, "Long", StringComparison.OrdinalIgnoreCase) || string.Equals(aiResponse.SuggestedDirection, "Buy", StringComparison.OrdinalIgnoreCase); if ((isLong && aiSuggestsShort) || (!isLong && aiSuggestsLong)) { newRecommendation = "Close"; reasoning = $"Trendwende detektiert: KI empfiehlt {aiResponse.SuggestedDirection}, Trade ist aber {(isLong ? "Long" : "Short")}."; } else if (string.Equals(aiResponse.AiDecision, "Reject", StringComparison.OrdinalIgnoreCase)) { newRecommendation = "Close"; reasoning = $"Risiko zu hoch: KI empfiehlt Exit. ({aiResponse.AiReasoning})"; } else if (aiResponse.ExecutionPlan != null) { // Ratchet / Trailing Logic: StopLoss darf das Risiko nicht vergrößern! if (aiResponse.ExecutionPlan.StopLoss > 0) { var proposedSl = aiResponse.ExecutionPlan.StopLoss; if (isLong) { // Bei Long darf der StopLoss nur NACH OBEN angepasst werden if (trade.StopLoss <= 0 || proposedSl > trade.StopLoss) { newStopLoss = proposedSl; if (proposedSl > trade.StopLoss && trade.StopLoss > 0) newRecommendation = "AdjustSL"; } } else { // Bei Short darf der StopLoss nur NACH UNTEN angepasst werden if (trade.StopLoss <= 0 || proposedSl < trade.StopLoss) { newStopLoss = proposedSl; if (proposedSl < trade.StopLoss && trade.StopLoss > 0) newRecommendation = "AdjustSL"; } } } if (aiResponse.ExecutionPlan.TakeProfitTargets != null && aiResponse.ExecutionPlan.TakeProfitTargets.Count > 0) { var proposedTp = aiResponse.ExecutionPlan.TakeProfitTargets[0]; if (proposedTp > 0 && proposedTp != trade.TakeProfit) { newTakeProfit = proposedTp; if (newRecommendation == "Hold") newRecommendation = "AdjustTP"; } } } await SendUpdateAsync(trade, currentPrice, newRecommendation, reasoning, newStopLoss, newTakeProfit); } private async Task SendUpdateAsync(TradeProposalDto trade, decimal currentPrice, string recommendation, string reasoning, decimal? suggestedStopLoss = null, decimal? suggestedTakeProfit = null) { var update = new TradeHourlyUpdateDto { TradeId = trade.TradeId, Recommendation = recommendation, CurrentPrice = currentPrice, SuggestedStopLoss = suggestedStopLoss, SuggestedTakeProfit = suggestedTakeProfit, VixValue = trade.VixValue, Reasoning = reasoning, Timestamp = DateTime.UtcNow }; // Direktes Objekt-Publishing nutzen (ManagedMqttClient serialisiert typgerecht) string topic = $"finlytic/trades/updates/{trade.Isin}"; await _mqttClient.PublishAsync(topic, update); _logger.LogInformation( "[{Channel}] Published trade update for {TradeId} to topic {Topic}. Recommendation: {Rec}, Reasoning: {Reason}", "AnalyzerChannel", trade.TradeId, topic, recommendation, reasoning); } private static int EstimateMaxHoldingDays(string timeframe) { if (string.IsNullOrWhiteSpace(timeframe)) return 14; // Default string tfLower = timeframe.ToLowerInvariant(); int multiplier = 1; if (tfLower.Contains("woche") || tfLower.Contains("week")) multiplier = 7; else if (tfLower.Contains("monat") || tfLower.Contains("month")) multiplier = 30; else if (tfLower.Contains("jahr") || tfLower.Contains("year")) multiplier = 365; var numbers = new List(); string currentNum = ""; foreach (char c in timeframe) { if (char.IsDigit(c)) { currentNum += c; } else if (currentNum.Length > 0) { if (int.TryParse(currentNum, out int n)) numbers.Add(n); currentNum = ""; } } if (currentNum.Length > 0 && int.TryParse(currentNum, out int lastN)) numbers.Add(lastN); int maxNum = numbers.Count > 0 ? numbers.Max() : 14; if (maxNum == 0) maxNum = 14; if (multiplier == 1 && maxNum < 3) maxNum = 3; // Mindestens 3 Tage Kulanz return maxNum * multiplier; } }