357 lines
14 KiB
C#
357 lines
14 KiB
C#
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<ActiveTradeMonitorWorker> _logger;
|
|
private readonly IServiceScopeFactory _scopeFactory;
|
|
private readonly AnalyzerMqttClient _mqttClient;
|
|
|
|
public ActiveTradeMonitorWorker(ILogger<ActiveTradeMonitorWorker> 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<List<TradeProposalDto>, GetTradesRequest>(
|
|
"trades_Get",
|
|
new GetTradesRequest(null, "Active"),
|
|
TimeSpan.FromSeconds(10));
|
|
|
|
// Fetch proposed global trades
|
|
var proposedTrades = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
|
|
"trades_Get",
|
|
new GetTradesRequest(null, "Proposed"),
|
|
TimeSpan.FromSeconds(10));
|
|
|
|
var trades = new List<TradeProposalDto>();
|
|
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<IN8nEvaluationService>();
|
|
|
|
foreach (var trade in trades)
|
|
{
|
|
if (cancellationToken.IsCancellationRequested) break;
|
|
|
|
try
|
|
{
|
|
await ProcessTradeAsync(trade, n8nService, 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,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// 1. Get Live Price
|
|
var livePriceReq = new IsinRequest(trade.Isin);
|
|
var livePriceDto = await _mqttClient.SendRpcRequestAsync<LivePriceDto, IsinRequest>(
|
|
"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<TechnicalAnalysisDto, IsinRequest>(
|
|
"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<PatternContextInfo>()
|
|
};
|
|
|
|
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 = trade.VixValue,
|
|
MarketRegime = trade.VixRegime.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<int>();
|
|
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;
|
|
}
|
|
} |