feat(Analyzer): refactor analyzer and implement auto mode
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public interface IN8nEvaluationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates an asset asynchronously using N8n.
|
||||
/// </summary>
|
||||
Task<N8nAnalysisResponseDto?> EvaluateAssetAsync(N8nAnalysisRequestDto request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Dtos.News;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public class FilterResult
|
||||
{
|
||||
public bool Passed { get; set; }
|
||||
public string RejectReason { get; set; } = string.Empty;
|
||||
|
||||
public string Sector { get; set; } = string.Empty;
|
||||
public string Symbol { get; set; } = string.Empty;
|
||||
public string Isin { get; set; } = string.Empty;
|
||||
|
||||
public double ImpactScore { get; set; }
|
||||
public double ThresholdApplied { get; set; }
|
||||
|
||||
public string RiskTolerance { get; set; } = "Moderate";
|
||||
public string Timeframe { get; set; } = "1D";
|
||||
public string InstrumentType { get; set; } = "Stock";
|
||||
}
|
||||
|
||||
public interface IThreeLayerFilterEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Evaluates news based on market regime and returns a filter result.
|
||||
/// </summary>
|
||||
FilterResult EvaluateNews(NewsArticleDto newsEvent, VixMarketRegime regime);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public interface IVixTrackerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current VIX value.
|
||||
/// </summary>
|
||||
decimal GetCurrentVix();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current market regime based on VIX.
|
||||
/// </summary>
|
||||
VixMarketRegime GetCurrentRegime();
|
||||
|
||||
/// <summary>
|
||||
/// Updates the VIX tracker with a new tick value.
|
||||
/// </summary>
|
||||
void UpdateVixFromTick(decimal vixValue);
|
||||
|
||||
/// <summary>
|
||||
/// Polls the VIX asynchronously and returns its value.
|
||||
/// </summary>
|
||||
Task<decimal> PollVixAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public interface IWinRateCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// Calculates the win rate for a given sector and symbol under the specified market regime.
|
||||
/// </summary>
|
||||
double CalculateWinRate(string sector, string symbol, VixMarketRegime regime);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public enum LogCategory
|
||||
{
|
||||
MqttHealthPing,
|
||||
MqttGeneral,
|
||||
AnalyzerAuto,
|
||||
AnalyzerManual,
|
||||
DatabaseOps,
|
||||
General
|
||||
}
|
||||
|
||||
public static class LogCategoryFilter
|
||||
{
|
||||
public static bool EnableLogMqttHealthPing { get; set; } = false;
|
||||
public static bool EnableLogMqttGeneral { get; set; } = true;
|
||||
public static bool EnableLogAnalyzerAuto { get; set; } = true;
|
||||
public static bool EnableLogAnalyzerManual { get; set; } = true;
|
||||
public static bool EnableLogDatabaseOps { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a given log category is enabled.
|
||||
/// </summary>
|
||||
public static bool IsEnabled(LogCategory category)
|
||||
{
|
||||
return category switch
|
||||
{
|
||||
LogCategory.MqttHealthPing => EnableLogMqttHealthPing,
|
||||
LogCategory.MqttGeneral => EnableLogMqttGeneral,
|
||||
LogCategory.AnalyzerAuto => EnableLogAnalyzerAuto,
|
||||
LogCategory.AnalyzerManual => EnableLogAnalyzerManual,
|
||||
LogCategory.DatabaseOps => EnableLogDatabaseOps,
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Text.Json;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Util;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public class N8nEvaluationService : IN8nEvaluationService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<N8nEvaluationService> _logger;
|
||||
private readonly string _webhookUrl;
|
||||
|
||||
public N8nEvaluationService(HttpClient httpClient, IConfiguration configuration, ILogger<N8nEvaluationService> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
_webhookUrl = configuration["N8N:WebhookUrl"] ?? configuration["N8N__WebhookUrl"] ?? "https://n8n.kleidukos.me/webhook/gemini/analysis/auto";
|
||||
|
||||
// Timeout auf 45 Sekunden erhöht für komplexere LLM/Gemini Chains in n8n
|
||||
_httpClient.Timeout = TimeSpan.FromSeconds(45);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates an asset asynchronously using N8n / Gemini workflows.
|
||||
/// </summary>
|
||||
public async Task<N8nAnalysisResponseDto?> EvaluateAssetAsync(N8nAnalysisRequestDto request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] Sending n8n AI Evaluation request {RequestId} for Asset {Symbol} (ISIN: {Isin}) to {Url}...",
|
||||
"AnalyzerChannel", request.RequestId, request.TargetAsset.Symbol, request.TargetAsset.Isin, _webhookUrl);
|
||||
|
||||
// Typsichere AOT-Serialisierung verwenden
|
||||
using var content = JsonContent.Create(
|
||||
request,
|
||||
FinlyticJsonSerializerContext.Default.N8nAnalysisRequestDto);
|
||||
|
||||
using var response = await _httpClient.PostAsync(_webhookUrl, content, cancellationToken);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var contentStr = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(contentStr) || contentStr.Trim() == "{}" || contentStr.Trim() == "[]")
|
||||
{
|
||||
_logger.LogWarning("[{Channel}] n8n Webhook returned an EMPTY response for Request {RequestId}. Flagging as AI Rejection (Too Risky).", "AnalyzerChannel", request.RequestId);
|
||||
return CreateRejectionFallback(request, "Die KI (n8n/Gemini) stuft den Trade als zu riskant ein und empfiehlt keine Positionierung.");
|
||||
}
|
||||
|
||||
// N8n schickt Ergebnisse manchmal als JSON-Array [{...}] zurück
|
||||
string jsonToDeserialize = contentStr.Trim();
|
||||
if (jsonToDeserialize.StartsWith('[') && jsonToDeserialize.EndsWith(']'))
|
||||
{
|
||||
using var doc = JsonDocument.Parse(jsonToDeserialize);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0)
|
||||
{
|
||||
jsonToDeserialize = doc.RootElement[0].GetRawText();
|
||||
}
|
||||
}
|
||||
|
||||
var responseDto = JsonSerializer.Deserialize(
|
||||
jsonToDeserialize,
|
||||
FinlyticJsonSerializerContext.Default.N8nAnalysisResponseDto);
|
||||
|
||||
if (responseDto != null && !string.IsNullOrWhiteSpace(responseDto.AiDecision))
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] Received n8n AI Response for Request {RequestId}: Decision={Decision}, Score={Score:F2}, Direction={Direction}, Timeframe={Timeframe}",
|
||||
"AnalyzerChannel", request.RequestId, responseDto.AiDecision, responseDto.EvalScore, responseDto.SuggestedDirection, responseDto.SuggestedTimeframe);
|
||||
|
||||
return responseDto;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("[{Channel}] n8n Webhook returned HTTP {StatusCode} for Request {RequestId}",
|
||||
"AnalyzerChannel", response.StatusCode, request.RequestId);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Timeout waiting for n8n AI Evaluation Webhook (Request {RequestId}). Consider increasing timeout.", "AnalyzerChannel", request.RequestId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Error calling n8n AI Evaluation Webhook for Request {RequestId}", "AnalyzerChannel", request.RequestId);
|
||||
}
|
||||
|
||||
return null; // Signals RPC/Service failure to caller
|
||||
}
|
||||
|
||||
private static N8nAnalysisResponseDto CreateRejectionFallback(N8nAnalysisRequestDto request, string reasoning)
|
||||
{
|
||||
return new N8nAnalysisResponseDto
|
||||
{
|
||||
RequestId = request.RequestId,
|
||||
AiDecision = "Rejected",
|
||||
EvalScore = 0.0,
|
||||
SuggestedDirection = "NONE",
|
||||
SuggestedRisk = request.UserPreferences?.RiskTolerance ?? "Moderate",
|
||||
SuggestedTimeframe = request.UserPreferences?.TimeframeFormatted ?? "1D",
|
||||
AiReasoning = reasoning
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using FinlyticAnalyzer.Database;
|
||||
using FinlyticAnalyzer.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public interface ISettingsDbService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the analyzer settings asynchronously.
|
||||
/// </summary>
|
||||
Task<AnalyzerSettingsEntity> GetSettingsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Saves the analyzer settings asynchronously.
|
||||
/// </summary>
|
||||
Task<AnalyzerSettingsEntity> SaveSettingsAsync(AnalyzerSettingsEntity settings);
|
||||
|
||||
/// <summary>
|
||||
/// Updates settings from a dictionary asynchronously.
|
||||
/// </summary>
|
||||
Task UpdateSettingsFromDictionaryAsync(Dictionary<string, string> dictionary);
|
||||
}
|
||||
|
||||
public class SettingsDbService : ISettingsDbService
|
||||
{
|
||||
private readonly AnalyzerDbContext _context;
|
||||
|
||||
public SettingsDbService(AnalyzerDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the analyzer settings asynchronously.
|
||||
/// </summary>
|
||||
public async Task<AnalyzerSettingsEntity> GetSettingsAsync()
|
||||
{
|
||||
var settings = await _context.Settings.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (settings == null)
|
||||
{
|
||||
settings = new AnalyzerSettingsEntity { Id = Guid.NewGuid() };
|
||||
_context.Settings.Add(settings);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the analyzer settings asynchronously.
|
||||
/// </summary>
|
||||
public async Task<AnalyzerSettingsEntity> SaveSettingsAsync(AnalyzerSettingsEntity settings)
|
||||
{
|
||||
var existing = await _context.Settings.FirstOrDefaultAsync();
|
||||
if (existing == null)
|
||||
{
|
||||
if (settings.Id == Guid.Empty) settings.Id = Guid.NewGuid();
|
||||
_context.Settings.Add(settings);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ScanCronSchedule = settings.ScanCronSchedule;
|
||||
existing.MinSignalScore = settings.MinSignalScore;
|
||||
existing.EnableLogMqttHealthPing = settings.EnableLogMqttHealthPing;
|
||||
existing.EnableLogMqttGeneral = settings.EnableLogMqttGeneral;
|
||||
existing.EnableLogAnalyzerAuto = settings.EnableLogAnalyzerAuto;
|
||||
existing.EnableLogAnalyzerManual = settings.EnableLogAnalyzerManual;
|
||||
existing.EnableLogDatabaseOps = settings.EnableLogDatabaseOps;
|
||||
existing.UpdatedAt = settings.UpdatedAt;
|
||||
_context.Settings.Update(existing);
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Synchronize in-memory static filter values
|
||||
LogCategoryFilter.EnableLogMqttHealthPing = settings.EnableLogMqttHealthPing;
|
||||
LogCategoryFilter.EnableLogMqttGeneral = settings.EnableLogMqttGeneral;
|
||||
LogCategoryFilter.EnableLogAnalyzerAuto = settings.EnableLogAnalyzerAuto;
|
||||
LogCategoryFilter.EnableLogAnalyzerManual = settings.EnableLogAnalyzerManual;
|
||||
LogCategoryFilter.EnableLogDatabaseOps = settings.EnableLogDatabaseOps;
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates settings from a dictionary asynchronously.
|
||||
/// </summary>
|
||||
public async Task UpdateSettingsFromDictionaryAsync(Dictionary<string, string> dictionary)
|
||||
{
|
||||
var settings = await GetSettingsAsync();
|
||||
|
||||
foreach (var (key, value) in dictionary)
|
||||
{
|
||||
if (string.Equals(key, "ScanCronSchedule", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(value))
|
||||
settings.ScanCronSchedule = value.Trim();
|
||||
else if (string.Equals(key, "MinSignalScore", StringComparison.OrdinalIgnoreCase) && double.TryParse(value, out var score))
|
||||
settings.MinSignalScore = score;
|
||||
else if (string.Equals(key, "EnableLog_MqttHealthPing", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b1))
|
||||
settings.EnableLogMqttHealthPing = b1;
|
||||
else if (string.Equals(key, "EnableLog_MqttGeneral", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b2))
|
||||
settings.EnableLogMqttGeneral = b2;
|
||||
else if (string.Equals(key, "EnableLog_AnalyzerAuto", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b3))
|
||||
settings.EnableLogAnalyzerAuto = b3;
|
||||
else if (string.Equals(key, "EnableLog_AnalyzerManual", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b4))
|
||||
settings.EnableLogAnalyzerManual = b4;
|
||||
else if (string.Equals(key, "EnableLog_DatabaseOps", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b5))
|
||||
settings.EnableLogDatabaseOps = b5;
|
||||
}
|
||||
|
||||
settings.UpdatedAt = DateTime.UtcNow;
|
||||
await SaveSettingsAsync(settings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using FinlyticCore.Dtos.News;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
|
||||
{
|
||||
private readonly ILogger<ThreeLayerFilterEngine> _logger;
|
||||
private readonly ConcurrentDictionary<string, DateTime> _seenEvents = new();
|
||||
private DateTime _lastCleanupTime = DateTime.UtcNow;
|
||||
|
||||
public ThreeLayerFilterEngine(ILogger<ThreeLayerFilterEngine> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates news strictly based on ISIN and dynamic VIX market regime.
|
||||
/// </summary>
|
||||
public FilterResult EvaluateNews(NewsArticleDto newsEvent, VixMarketRegime regime)
|
||||
{
|
||||
var result = new FilterResult();
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Layer 1: Relevance, ISIN & Deduplication
|
||||
// -------------------------------------------------------------
|
||||
if (newsEvent == null || newsEvent.Id == Guid.Empty)
|
||||
{
|
||||
result.Passed = false;
|
||||
result.RejectReason = "Layer 1: Missing or Empty NewsArticle / EventId";
|
||||
return result;
|
||||
}
|
||||
|
||||
string eventId = newsEvent.Id.ToString();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Safely clean up dictionary every 30 minutes
|
||||
if ((now - _lastCleanupTime).TotalMinutes > 30 || _seenEvents.Count > 10000)
|
||||
{
|
||||
CleanupSeenEvents(now);
|
||||
}
|
||||
|
||||
// Deduplication check (keep history for 12 hours)
|
||||
if (_seenEvents.TryGetValue(eventId, out var prevTime) && (now - prevTime).TotalHours < 12.0)
|
||||
{
|
||||
result.Passed = false;
|
||||
result.RejectReason = "Layer 1: Duplicate EventId within 12h window";
|
||||
return result;
|
||||
}
|
||||
|
||||
_seenEvents[eventId] = now;
|
||||
|
||||
string isin = string.Empty;
|
||||
string assetName = string.Empty;
|
||||
|
||||
// Extract parameters strictly from MatchedAssets
|
||||
if (newsEvent.MatchedAssets != null && newsEvent.MatchedAssets.Count > 0)
|
||||
{
|
||||
var firstAsset = newsEvent.MatchedAssets[0];
|
||||
isin = !string.IsNullOrWhiteSpace(firstAsset.Isin) ? firstAsset.Isin.Trim().ToUpperInvariant() : string.Empty;
|
||||
assetName = !string.IsNullOrWhiteSpace(firstAsset.Name) ? firstAsset.Name.Trim() : string.Empty;
|
||||
}
|
||||
|
||||
// Mandatory check: Must have a valid ISIN
|
||||
if (string.IsNullOrWhiteSpace(isin))
|
||||
{
|
||||
result.Passed = false;
|
||||
result.RejectReason = "Layer 1: Missing mandatory ISIN for news item";
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Isin = isin;
|
||||
// Asset-Symbol fallback to ISIN, Name is mapped appropriately later
|
||||
result.Symbol = isin;
|
||||
result.Sector = "General"; // Will be enriched downstream via Fundamentals RPC if available
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Layer 2: Impact & Dynamic VIX Threshold
|
||||
// -------------------------------------------------------------
|
||||
double impactScore = newsEvent.Confidence ?? 0.75;
|
||||
if (impactScore <= 0) impactScore = 0.75;
|
||||
|
||||
double requiredThreshold = regime switch
|
||||
{
|
||||
VixMarketRegime.LowVol => 0.55,
|
||||
VixMarketRegime.Normal => 0.65,
|
||||
VixMarketRegime.HighVol => 0.80,
|
||||
VixMarketRegime.Panic => 0.90,
|
||||
_ => 0.65
|
||||
};
|
||||
|
||||
result.ImpactScore = impactScore;
|
||||
result.ThresholdApplied = requiredThreshold;
|
||||
|
||||
if (impactScore < requiredThreshold)
|
||||
{
|
||||
result.Passed = false;
|
||||
result.RejectReason = $"Layer 2: Impact score ({impactScore:F2}) below dynamic VIX threshold ({requiredThreshold:F2}) for regime {regime}";
|
||||
_logger.LogInformation("[{Channel}] Event {EventId} (ISIN: {Isin}) rejected by Layer 2 filter. Impact: {Impact:F2}, Threshold: {Threshold:F2}, Regime: {Regime}",
|
||||
"AnalyzerChannel", eventId, isin, impactScore, requiredThreshold, regime);
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Layer 3: Dynamic Parameter & Risk Engine
|
||||
// -------------------------------------------------------------
|
||||
result.RiskTolerance = regime switch
|
||||
{
|
||||
VixMarketRegime.Panic => "Conservative",
|
||||
VixMarketRegime.HighVol => "Moderate",
|
||||
_ => "Aggressive"
|
||||
};
|
||||
|
||||
result.Timeframe = impactScore >= 0.85 ? "4H" : "1D";
|
||||
result.InstrumentType = regime == VixMarketRegime.Panic ? "Option" : "Stock";
|
||||
|
||||
result.Passed = true;
|
||||
_logger.LogInformation("[{Channel}] Event {EventId} passed 3-Layer Filter for ISIN {Isin}. Impact: {Impact:F2}, Regime: {Regime}",
|
||||
"AnalyzerChannel", eventId, result.Isin, impactScore, regime);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void CleanupSeenEvents(DateTime now)
|
||||
{
|
||||
_lastCleanupTime = now;
|
||||
foreach (var kv in _seenEvents)
|
||||
{
|
||||
if ((now - kv.Value).TotalHours > 12.0)
|
||||
{
|
||||
_seenEvents.TryRemove(kv.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Services.Yahoo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public class VixTrackerService : IVixTrackerService
|
||||
{
|
||||
private readonly YahooFinanceClient _yahooClient;
|
||||
private readonly ILogger<VixTrackerService> _logger;
|
||||
|
||||
private decimal _currentVix = 18.5m; // Default: Normal Regime
|
||||
private VixMarketRegime _currentRegime = VixMarketRegime.Normal;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public VixTrackerService(YahooFinanceClient yahooClient, ILogger<VixTrackerService> logger)
|
||||
{
|
||||
_yahooClient = yahooClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public decimal GetCurrentVix()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _currentVix;
|
||||
}
|
||||
}
|
||||
|
||||
public VixMarketRegime GetCurrentRegime()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _currentRegime;
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateVixFromTick(decimal vixValue)
|
||||
{
|
||||
if (vixValue <= 0m) return;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var oldRegime = _currentRegime;
|
||||
var oldVix = _currentVix;
|
||||
|
||||
_currentVix = vixValue;
|
||||
_currentRegime = CalculateRegime(vixValue);
|
||||
|
||||
if (oldRegime != _currentRegime)
|
||||
{
|
||||
_logger.LogWarning("[{Channel}] [VIX REGIME SHIFT] Markt-Regime gewechselt: {OldRegime} -> {NewRegime} (VIX: {Vix:F2})",
|
||||
"AnalyzerChannel", oldRegime, _currentRegime, _currentVix);
|
||||
}
|
||||
else if (Math.Abs(oldVix - vixValue) >= 0.5m)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] VIX aktualisiert: {Vix:F2} (Regime: {Regime})",
|
||||
"AnalyzerChannel", _currentVix, _currentRegime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<decimal> PollVixAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var vix = await _yahooClient.GetLivePriceAsync("^VIX", cancellationToken);
|
||||
|
||||
if (vix.HasValue && vix.Value > 0m)
|
||||
{
|
||||
UpdateVixFromTick(vix.Value);
|
||||
return vix.Value;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Graceful shutdown
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[{Channel}] Fehler beim Abfragen von ^VIX über YahooFinanceClient. Nutze gecachten Wert {Vix}.",
|
||||
"AnalyzerChannel", GetCurrentVix());
|
||||
}
|
||||
|
||||
return GetCurrentVix();
|
||||
}
|
||||
|
||||
private static VixMarketRegime CalculateRegime(decimal vix)
|
||||
{
|
||||
return vix switch
|
||||
{
|
||||
< 15.0m => VixMarketRegime.LowVol,
|
||||
>= 15.0m and < 20.0m => VixMarketRegime.Normal,
|
||||
>= 20.0m and < 30.0m => VixMarketRegime.HighVol,
|
||||
_ => VixMarketRegime.Panic
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Models.Trades;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public class WinRateCalculator : IWinRateCalculator
|
||||
{
|
||||
private readonly ILogger<WinRateCalculator> _logger;
|
||||
private readonly string _feedbackDir;
|
||||
|
||||
public WinRateCalculator(ILogger<WinRateCalculator> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_feedbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "feedback");
|
||||
if (!Directory.Exists(_feedbackDir))
|
||||
{
|
||||
Directory.CreateDirectory(_feedbackDir);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the win rate for a given sector and symbol under the specified market regime.
|
||||
/// </summary>
|
||||
public double CalculateWinRate(string sector, string symbol, VixMarketRegime regime)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_feedbackDir)) return 65.0;
|
||||
|
||||
var jsonFiles = Directory.GetFiles(_feedbackDir, "*.json", SearchOption.AllDirectories);
|
||||
if (jsonFiles.Length == 0) return 65.0;
|
||||
|
||||
int totalTrades = 0;
|
||||
int winningTrades = 0;
|
||||
|
||||
foreach (var file in jsonFiles)
|
||||
{
|
||||
var content = File.ReadAllText(file);
|
||||
var records = JsonSerializer.Deserialize<TradeFeedbackRecord[]>(content);
|
||||
if (records == null || records.Length == 0) continue;
|
||||
|
||||
var matching = records.Where(r =>
|
||||
string.Equals(r.Sector, sector, StringComparison.OrdinalIgnoreCase) &&
|
||||
r.VixRegime == regime).ToList();
|
||||
|
||||
foreach (var rec in matching)
|
||||
{
|
||||
totalTrades++;
|
||||
if (rec.IsWin) winningTrades++;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalTrades > 0)
|
||||
{
|
||||
double calculatedWinRate = (double)winningTrades / totalTrades * 100.0;
|
||||
_logger.LogInformation("[{Channel}] Calculated win-rate for Sector '{Sector}' in Regime '{Regime}': {WinRate:F1}% ({Wins}/{Total})",
|
||||
"AnalyzerChannel", sector, regime, calculatedWinRate, winningTrades, totalTrades);
|
||||
return Math.Round(calculatedWinRate, 1);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[{Channel}] Error reading feedback files for win-rate calculation. Falling back to default.", "AnalyzerChannel");
|
||||
}
|
||||
|
||||
return 65.0; // Default baseline win-rate
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user