feat(analyzer): dynamic settings, IFinlyticLogger, live log streaming, and EF migration

This commit is contained in:
2026-08-15 21:30:16 +02:00
parent 62e030e2cf
commit 0d370d09e7
13 changed files with 687 additions and 215 deletions
@@ -5,11 +5,12 @@ using System.Threading.Tasks;
using FinlyticAnalyzer.Database; using FinlyticAnalyzer.Database;
using FinlyticAnalyzer.Entities; using FinlyticAnalyzer.Entities;
using FinlyticAnalyzer.Services; using FinlyticAnalyzer.Services;
using FinlyticAnalyzer.Util;
using FinlyticCore.Models.Analyzer; using FinlyticCore.Models.Analyzer;
using FinlyticCore.Models.Trades; using FinlyticCore.Models.Trades;
using FinlyticCore.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace FinlyticAnalyzer.Controllers; namespace FinlyticAnalyzer.Controllers;
@@ -36,20 +37,20 @@ public class ManualAnalysisController : ControllerBase
private readonly IN8nEvaluationService _n8nService; private readonly IN8nEvaluationService _n8nService;
private readonly IWinRateCalculator _winRateCalculator; private readonly IWinRateCalculator _winRateCalculator;
private readonly AnalyzerDbContext _dbContext; private readonly AnalyzerDbContext _dbContext;
private readonly ILogger<ManualAnalysisController> _logger; private readonly IFinlyticLogger<ManualAnalysisController> _finlyticLogger;
public ManualAnalysisController( public ManualAnalysisController(
IVixTrackerService vixTracker, IVixTrackerService vixTracker,
IN8nEvaluationService n8nService, IN8nEvaluationService n8nService,
IWinRateCalculator winRateCalculator, IWinRateCalculator winRateCalculator,
AnalyzerDbContext dbContext, AnalyzerDbContext dbContext,
ILogger<ManualAnalysisController> logger) IFinlyticLogger<ManualAnalysisController> finlyticLogger)
{ {
_vixTracker = vixTracker; _vixTracker = vixTracker;
_n8nService = n8nService; _n8nService = n8nService;
_winRateCalculator = winRateCalculator; _winRateCalculator = winRateCalculator;
_dbContext = dbContext; _dbContext = dbContext;
_logger = logger; _finlyticLogger = finlyticLogger;
} }
/// <summary> /// <summary>
@@ -181,6 +182,8 @@ public class ManualAnalysisController : ControllerBase
_dbContext.Analyses.Add(analysisEntity); _dbContext.Analyses.Add(analysisEntity);
await _dbContext.SaveChangesAsync(cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken);
await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ManualAnalysisController] Manual analysis completed for {Symbol} (TradeProposed: {Proposed})", request.Symbol, shouldProceed);
if (!shouldProceed) if (!shouldProceed)
{ {
return Ok(new return Ok(new
+14 -2
View File
@@ -1,10 +1,12 @@
using FinlyticAnalyzer.Entities; using FinlyticAnalyzer.Entities;
using FinlyticCore.Database;
using FinlyticCore.Entities.Settings; using FinlyticCore.Entities.Settings;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace FinlyticAnalyzer.Database; namespace FinlyticAnalyzer.Database;
public class AnalyzerDbContext : DbContext public class AnalyzerDbContext : DbContext, ISettingsDbContext
{ {
public AnalyzerDbContext(DbContextOptions<AnalyzerDbContext> options) : base(options) { } public AnalyzerDbContext(DbContextOptions<AnalyzerDbContext> options) : base(options) { }
@@ -20,7 +22,7 @@ public class AnalyzerDbContext : DbContext
modelBuilder.Entity<SettingEntity>(entity => modelBuilder.Entity<SettingEntity>(entity =>
{ {
entity.HasKey(e => e.Id); entity.HasKey(e => e.Id);
entity.HasIndex(e => e.Key); entity.HasIndex(e => e.Key).IsUnique();
}); });
modelBuilder.Entity<AnalysisEntity>(entity => modelBuilder.Entity<AnalysisEntity>(entity =>
@@ -39,3 +41,13 @@ public class AnalyzerDbContext : DbContext
}); });
} }
} }
public class AnalyzerDbContextFactory : IDesignTimeDbContextFactory<AnalyzerDbContext>
{
public AnalyzerDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<AnalyzerDbContext>();
optionsBuilder.UseNpgsql("Host=localhost;Database=analyzer;Username=postgres;Password=postgres");
return new AnalyzerDbContext(optionsBuilder.Options);
}
}
@@ -0,0 +1,308 @@
// <auto-generated />
using System;
using FinlyticAnalyzer.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FinlyticAnalyzer.Migrations
{
[DbContext(typeof(AnalyzerDbContext))]
[Migration("20260815184017_AddDynamicSettings")]
partial class AddDynamicSettings
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalysisEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AiOutputJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("AnalysisId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EventId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<double>("ImpactScore")
.HasColumnType("double precision");
b.Property<bool>("IsTradeProposed")
.HasColumnType("boolean");
b.Property<string>("Isin")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("N8nDecision")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<double>("N8nEvalScore")
.HasColumnType("double precision");
b.Property<string>("N8nResponseJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("RawDataJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Sector")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<int>("VixRegime")
.HasColumnType("integer");
b.Property<decimal>("VixValue")
.HasColumnType("numeric");
b.Property<double>("WinRate")
.HasColumnType("double precision");
b.HasKey("Id");
b.HasIndex("AnalysisId")
.IsUnique();
b.HasIndex("CreatedAt");
b.HasIndex("EventId");
b.HasIndex("Isin");
b.HasIndex("Sector");
b.ToTable("analyses");
});
modelBuilder.Entity("FinlyticAnalyzer.Entities.AnalyzerSettingsEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("EnableLogAnalyzerAuto")
.HasColumnType("boolean");
b.Property<bool>("EnableLogAnalyzerManual")
.HasColumnType("boolean");
b.Property<bool>("EnableLogDatabaseOps")
.HasColumnType("boolean");
b.Property<bool>("EnableLogMqttGeneral")
.HasColumnType("boolean");
b.Property<bool>("EnableLogMqttHealthPing")
.HasColumnType("boolean");
b.Property<double>("MinSignalScore")
.HasColumnType("double precision");
b.Property<string>("ScanCronSchedule")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Settings");
});
modelBuilder.Entity("FinlyticAnalyzer.Entities.TradeProposalEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AnalysisId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<double>("ConfidenceScore")
.HasColumnType("double precision");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("EntryPrice")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("EntryZoneMax")
.HasColumnType("decimal(18,4)");
b.Property<decimal?>("EntryZoneMin")
.HasColumnType("decimal(18,4)");
b.Property<string>("EventId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FundamentalRationale")
.IsRequired()
.HasColumnType("text");
b.Property<string>("InstrumentType")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("Isin")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<decimal?>("MaxLeverage")
.HasColumnType("decimal(18,4)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<string>("ProposedAction")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ReasonSummary")
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("RiskRewardRatio")
.HasColumnType("decimal(18,4)");
b.Property<string>("RiskTolerance")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<string>("RiskWarning")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Sector")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<decimal>("StopLoss")
.HasColumnType("decimal(18,4)");
b.Property<string>("Symbol")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<decimal>("TakeProfit")
.HasColumnType("decimal(18,4)");
b.Property<string>("TakeProfitTargets")
.HasColumnType("text");
b.Property<string>("TechnicalRationale")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Timeframe")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<int>("VixRegime")
.HasColumnType("integer");
b.Property<decimal>("VixValue")
.HasColumnType("decimal(18,4)");
b.Property<double>("WinRate")
.HasColumnType("double precision");
b.HasKey("Id");
b.HasIndex("ExpiresAt");
b.HasIndex("Isin");
b.ToTable("trade_proposals");
});
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceIdentifier")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ValueJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("DynamicSettings");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,43 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FinlyticAnalyzer.Migrations
{
/// <inheritdoc />
public partial class AddDynamicSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DynamicSettings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Key = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
ValueJson = table.Column<string>(type: "text", nullable: false),
ServiceIdentifier = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
LastUpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DynamicSettings", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_DynamicSettings_Key",
table: "DynamicSettings",
column: "Key",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DynamicSettings");
}
}
}
@@ -268,6 +268,37 @@ namespace FinlyticAnalyzer.Migrations
b.ToTable("trade_proposals"); b.ToTable("trade_proposals");
}); });
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceIdentifier")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ValueJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("DynamicSettings");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
+11 -5
View File
@@ -2,14 +2,24 @@ using System;
using FinlyticAnalyzer.Database; using FinlyticAnalyzer.Database;
using FinlyticAnalyzer.Services; using FinlyticAnalyzer.Services;
using FinlyticAnalyzer.Util; using FinlyticAnalyzer.Util;
using FinlyticCore.Database;
using FinlyticCore.Services;
using FinlyticCore.Services.Yahoo; using FinlyticCore.Services.Yahoo;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args); var builder = Host.CreateApplicationBuilder(args);
// Register DB Context // Register DB Context
builder.Services.AddDbContext<AnalyzerDbContext>(options => builder.Services.AddDbContext<AnalyzerDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped<ISettingsDbContext>(sp => sp.GetRequiredService<AnalyzerDbContext>());
// Register Core Services
builder.Services.AddSingleton<ISettingsService, SettingsService>();
builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>));
// Register HTTP Clients for external webhooks (HttpClientFactory manages pool) // Register HTTP Clients for external webhooks (HttpClientFactory manages pool)
builder.Services.AddHttpClient<IN8nEvaluationService, N8nEvaluationService>(); builder.Services.AddHttpClient<IN8nEvaluationService, N8nEvaluationService>();
@@ -38,14 +48,10 @@ using (var scope = host.Services.CreateScope())
var context = scope.ServiceProvider.GetRequiredService<AnalyzerDbContext>(); var context = scope.ServiceProvider.GetRequiredService<AnalyzerDbContext>();
await context.Database.MigrateAsync(); await context.Database.MigrateAsync();
Console.WriteLine("Database migrations successfully executed for FinlyticAnalyzer."); Console.WriteLine("Database migrations successfully executed for FinlyticAnalyzer.");
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
await settingsService.GetSettingsAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>(); Console.WriteLine($"Critical error during database migration for FinlyticAnalyzer: {ex.Message}");
logger.LogError(ex, "An error occurred during database migration for FinlyticAnalyzer on startup.");
} }
} }
@@ -9,30 +9,32 @@ using FinlyticCore.Dtos;
using FinlyticCore.Dtos.TechnicalAnalysis; using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Models.Analyzer; using FinlyticCore.Models.Analyzer;
using FinlyticCore.Models.Trades; using FinlyticCore.Models.Trades;
using FinlyticCore.Services;
using FinlyticCore.Util; using FinlyticCore.Util;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace FinlyticAnalyzer.Services; namespace FinlyticAnalyzer.Services;
public class ActiveTradeMonitorWorker : BackgroundService public class ActiveTradeMonitorWorker : BackgroundService
{ {
private readonly ILogger<ActiveTradeMonitorWorker> _logger; private readonly IFinlyticLogger<ActiveTradeMonitorWorker> _finlyticLogger;
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly AnalyzerMqttClient _mqttClient; private readonly AnalyzerMqttClient _mqttClient;
public ActiveTradeMonitorWorker(ILogger<ActiveTradeMonitorWorker> logger, IServiceScopeFactory scopeFactory, public ActiveTradeMonitorWorker(
IFinlyticLogger<ActiveTradeMonitorWorker> finlyticLogger,
IServiceScopeFactory scopeFactory,
AnalyzerMqttClient mqttClient) AnalyzerMqttClient mqttClient)
{ {
_logger = logger; _finlyticLogger = finlyticLogger;
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
_mqttClient = mqttClient; _mqttClient = mqttClient;
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
_logger.LogInformation("[{Channel}] ActiveTradeMonitorWorker started.", "AnalyzerChannel"); await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] ActiveTradeMonitorWorker started.");
try try
{ {
@@ -51,7 +53,7 @@ public class ActiveTradeMonitorWorker : BackgroundService
} }
catch (Exception ex) when (!stoppingToken.IsCancellationRequested) catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{ {
_logger.LogError(ex, "[{Channel}] Error in ActiveTradeMonitorWorker loop.", "AnalyzerChannel"); await _finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, ex, "[ActiveTradeMonitorWorker] Error in ActiveTradeMonitorWorker loop.");
} }
try try
@@ -64,24 +66,22 @@ public class ActiveTradeMonitorWorker : BackgroundService
} }
} }
_logger.LogInformation("[{Channel}] ActiveTradeMonitorWorker stopped.", "AnalyzerChannel"); await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] ActiveTradeMonitorWorker stopped.");
} }
private async Task MonitorActiveTradesAsync(CancellationToken cancellationToken) private async Task MonitorActiveTradesAsync(CancellationToken cancellationToken)
{ {
if (!_mqttClient.IsConnected) if (!_mqttClient.IsConnected)
{ {
_logger.LogWarning("[{Channel}] Skipping trade monitoring. RPC client not connected.", "AnalyzerChannel"); await _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] Skipping trade monitoring. RPC client not connected.");
return; return;
} }
// Fetch active trades
var activeTrades = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>( var activeTrades = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
"trades_Get", "trades_Get",
new GetTradesRequest(null, "Active"), new GetTradesRequest(null, "Active"),
TimeSpan.FromSeconds(10)); TimeSpan.FromSeconds(10));
// Fetch proposed global trades
var proposedTrades = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>( var proposedTrades = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
"trades_Get", "trades_Get",
new GetTradesRequest(null, "Proposed"), new GetTradesRequest(null, "Proposed"),
@@ -93,13 +93,11 @@ public class ActiveTradeMonitorWorker : BackgroundService
if (trades.Count == 0) if (trades.Count == 0)
{ {
_logger.LogInformation("[{Channel}] No active or proposed global trades found to monitor.", await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] No active or proposed global trades found to monitor.");
"AnalyzerChannel");
return; return;
} }
_logger.LogInformation("[{Channel}] Found {Count} trades to monitor. Starting evaluation...", "AnalyzerChannel", await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] Found {Count} trades to monitor. Starting evaluation...", trades.Count);
trades.Count);
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var n8nService = scope.ServiceProvider.GetRequiredService<IN8nEvaluationService>(); var n8nService = scope.ServiceProvider.GetRequiredService<IN8nEvaluationService>();
@@ -115,8 +113,7 @@ public class ActiveTradeMonitorWorker : BackgroundService
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "[{Channel}] Failed to monitor trade {TradeId} ({Symbol}).", "AnalyzerChannel", await _finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, ex, "[ActiveTradeMonitorWorker] Failed to monitor trade {TradeId} ({Symbol}).", trade.TradeId, trade.Symbol);
trade.TradeId, trade.Symbol);
} }
} }
} }
@@ -124,22 +121,18 @@ public class ActiveTradeMonitorWorker : BackgroundService
private async Task ProcessTradeAsync(TradeProposalDto trade, IN8nEvaluationService n8nService, private async Task ProcessTradeAsync(TradeProposalDto trade, IN8nEvaluationService n8nService,
IVixTrackerService vixService, CancellationToken cancellationToken) IVixTrackerService vixService, CancellationToken cancellationToken)
{ {
// 1. Get Live Price
var livePriceReq = new IsinRequest(trade.Isin); var livePriceReq = new IsinRequest(trade.Isin);
var livePriceDto = await _mqttClient.SendRpcRequestAsync<LivePriceDto, IsinRequest>( var livePriceDto = await _mqttClient.SendRpcRequestAsync<LivePriceDto, IsinRequest>(
"tr_GetLivePrice", livePriceReq, TimeSpan.FromSeconds(3)); "tr_GetLivePrice", livePriceReq, TimeSpan.FromSeconds(3));
decimal currentPrice = livePriceDto?.CurrentPrice > 0 ? livePriceDto.CurrentPrice : trade.EntryPrice; 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) || bool isLong = string.Equals(trade.SignalType, "BUY", StringComparison.OrdinalIgnoreCase) ||
string.Equals(trade.SignalType, "LONG", StringComparison.OrdinalIgnoreCase); string.Equals(trade.SignalType, "LONG", StringComparison.OrdinalIgnoreCase);
// Time-Stop Evaluierung
int maxHoldingDays = EstimateMaxHoldingDays(trade.Timeframe); int maxHoldingDays = EstimateMaxHoldingDays(trade.Timeframe);
double daysOpen = (DateTime.UtcNow - trade.CreatedAt).TotalDays; 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)) if (daysOpen > (maxHoldingDays * 1.5))
{ {
await SendUpdateAsync(trade, currentPrice, "Close", await SendUpdateAsync(trade, currentPrice, "Close",
@@ -176,7 +169,6 @@ public class ActiveTradeMonitorWorker : BackgroundService
} }
} }
// 3. Run AI evaluation for soft/dynamic updates
var taResult = await _mqttClient.SendRpcRequestAsync<TechnicalAnalysisDto, IsinRequest>( var taResult = await _mqttClient.SendRpcRequestAsync<TechnicalAnalysisDto, IsinRequest>(
"ta_GetAnalysis", livePriceReq, TimeSpan.FromSeconds(5)); "ta_GetAnalysis", livePriceReq, TimeSpan.FromSeconds(5));
@@ -225,8 +217,7 @@ public class ActiveTradeMonitorWorker : BackgroundService
var aiResponse = await n8nService.EvaluateAssetAsync(n8nReq, cancellationToken); var aiResponse = await n8nService.EvaluateAssetAsync(n8nReq, cancellationToken);
if (aiResponse == null) if (aiResponse == null)
{ {
_logger.LogWarning("[{Channel}] AI evaluation returned null for {TradeId}. Skipping update.", await _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] AI evaluation returned null for {TradeId}. Skipping update.", trade.TradeId);
"AnalyzerChannel", trade.TradeId);
return; return;
} }
@@ -235,7 +226,6 @@ public class ActiveTradeMonitorWorker : BackgroundService
decimal? newStopLoss = trade.StopLoss; decimal? newStopLoss = trade.StopLoss;
decimal? newTakeProfit = trade.TakeProfit; decimal? newTakeProfit = trade.TakeProfit;
// Check for trend reversal
bool aiSuggestsShort = bool aiSuggestsShort =
string.Equals(aiResponse.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) || string.Equals(aiResponse.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) ||
string.Equals(aiResponse.SuggestedDirection, "Sell", StringComparison.OrdinalIgnoreCase); string.Equals(aiResponse.SuggestedDirection, "Sell", StringComparison.OrdinalIgnoreCase);
@@ -256,13 +246,11 @@ public class ActiveTradeMonitorWorker : BackgroundService
} }
else if (aiResponse.ExecutionPlan != null) else if (aiResponse.ExecutionPlan != null)
{ {
// Ratchet / Trailing Logic: StopLoss darf das Risiko nicht vergrößern!
if (aiResponse.ExecutionPlan.StopLoss > 0) if (aiResponse.ExecutionPlan.StopLoss > 0)
{ {
var proposedSl = aiResponse.ExecutionPlan.StopLoss; var proposedSl = aiResponse.ExecutionPlan.StopLoss;
if (isLong) if (isLong)
{ {
// Bei Long darf der StopLoss nur NACH OBEN angepasst werden
if (trade.StopLoss <= 0 || proposedSl > trade.StopLoss) if (trade.StopLoss <= 0 || proposedSl > trade.StopLoss)
{ {
newStopLoss = proposedSl; newStopLoss = proposedSl;
@@ -271,7 +259,6 @@ public class ActiveTradeMonitorWorker : BackgroundService
} }
else else
{ {
// Bei Short darf der StopLoss nur NACH UNTEN angepasst werden
if (trade.StopLoss <= 0 || proposedSl < trade.StopLoss) if (trade.StopLoss <= 0 || proposedSl < trade.StopLoss)
{ {
newStopLoss = proposedSl; newStopLoss = proposedSl;
@@ -310,18 +297,16 @@ public class ActiveTradeMonitorWorker : BackgroundService
Timestamp = DateTime.UtcNow Timestamp = DateTime.UtcNow
}; };
// Direktes Objekt-Publishing nutzen (ManagedMqttClient serialisiert typgerecht)
string topic = $"finlytic/trades/updates/{trade.Isin}"; string topic = $"finlytic/trades/updates/{trade.Isin}";
await _mqttClient.PublishAsync(topic, update); await _mqttClient.PublishAsync(topic, update);
_logger.LogInformation( await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] Published trade update for {TradeId} to topic {Topic}. Recommendation: {Rec}, Reasoning: {Reason}",
"[{Channel}] Published trade update for {TradeId} to topic {Topic}. Recommendation: {Rec}, Reasoning: {Reason}", trade.TradeId, topic, recommendation, reasoning);
"AnalyzerChannel", trade.TradeId, topic, recommendation, reasoning);
} }
private static int EstimateMaxHoldingDays(string timeframe) private static int EstimateMaxHoldingDays(string timeframe)
{ {
if (string.IsNullOrWhiteSpace(timeframe)) return 14; // Default if (string.IsNullOrWhiteSpace(timeframe)) return 14;
string tfLower = timeframe.ToLowerInvariant(); string tfLower = timeframe.ToLowerInvariant();
int multiplier = 1; int multiplier = 1;
@@ -351,7 +336,7 @@ public class ActiveTradeMonitorWorker : BackgroundService
int maxNum = numbers.Count > 0 ? numbers.Max() : 14; int maxNum = numbers.Count > 0 ? numbers.Max() : 14;
if (maxNum == 0) maxNum = 14; if (maxNum == 0) maxNum = 14;
if (multiplier == 1 && maxNum < 3) maxNum = 3; // Mindestens 3 Tage Kulanz if (multiplier == 1 && maxNum < 3) maxNum = 3;
return maxNum * multiplier; return maxNum * multiplier;
} }
@@ -1,26 +1,33 @@
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FinlyticAnalyzer.Util;
using FinlyticCore.Models.Analyzer; using FinlyticCore.Models.Analyzer;
using FinlyticCore.Services;
using FinlyticCore.Util; using FinlyticCore.Util;
using Microsoft.Extensions.Configuration;
namespace FinlyticAnalyzer.Services; namespace FinlyticAnalyzer.Services;
public class N8nEvaluationService : IN8nEvaluationService public class N8nEvaluationService : IN8nEvaluationService
{ {
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly ILogger<N8nEvaluationService> _logger; private readonly IFinlyticLogger<N8nEvaluationService> _finlyticLogger;
private readonly string _webhookUrl; private readonly string _webhookUrl;
public N8nEvaluationService(HttpClient httpClient, IConfiguration configuration, ILogger<N8nEvaluationService> logger) public N8nEvaluationService(HttpClient httpClient, IConfiguration configuration, IFinlyticLogger<N8nEvaluationService> finlyticLogger)
{ {
_httpClient = httpClient; _httpClient = httpClient;
_logger = logger; _finlyticLogger = finlyticLogger;
_webhookUrl = configuration["N8N:WebhookUrl"] ?? configuration["N8N__WebhookUrl"] ?? string.Empty; _webhookUrl = configuration["N8N:WebhookUrl"] ?? configuration["N8N__WebhookUrl"] ?? string.Empty;
if (string.IsNullOrWhiteSpace(_webhookUrl)) if (string.IsNullOrWhiteSpace(_webhookUrl))
{ {
_logger.LogWarning("[{Channel}] N8N:WebhookUrl configuration is missing or empty.", "AnalyzerChannel"); _ = _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, "[N8nEvaluationService] N8N:WebhookUrl configuration is missing or empty.");
} }
// Timeout auf 45 Sekunden erhöht für komplexere LLM/Gemini Chains in n8n
_httpClient.Timeout = TimeSpan.FromSeconds(45); _httpClient.Timeout = TimeSpan.FromSeconds(45);
} }
@@ -31,16 +38,15 @@ public class N8nEvaluationService : IN8nEvaluationService
{ {
if (string.IsNullOrWhiteSpace(_webhookUrl)) if (string.IsNullOrWhiteSpace(_webhookUrl))
{ {
_logger.LogError("[{Channel}] Cannot execute AI evaluation for {Symbol}: N8N:WebhookUrl is not configured.", "AnalyzerChannel", request.TargetAsset.Symbol); await _finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, "[N8nEvaluationService] Cannot execute AI evaluation for {Symbol}: N8N:WebhookUrl is not configured.", request.TargetAsset.Symbol);
return null; return null;
} }
try try
{ {
_logger.LogInformation("[{Channel}] Sending n8n AI Evaluation request {RequestId} for Asset {Symbol} (ISIN: {Isin}) to {Url}...", await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[N8nEvaluationService] Sending n8n AI Evaluation request {RequestId} for Asset {Symbol} (ISIN: {Isin}) to {Url}...",
"AnalyzerChannel", request.RequestId, request.TargetAsset.Symbol, request.TargetAsset.Isin, _webhookUrl); request.RequestId, request.TargetAsset.Symbol, request.TargetAsset.Isin, _webhookUrl);
// Typsichere AOT-Serialisierung verwenden
using var content = JsonContent.Create( using var content = JsonContent.Create(
request, request,
FinlyticJsonSerializerContext.Default.N8nAnalysisRequestDto); FinlyticJsonSerializerContext.Default.N8nAnalysisRequestDto);
@@ -53,11 +59,10 @@ public class N8nEvaluationService : IN8nEvaluationService
if (string.IsNullOrWhiteSpace(contentStr) || contentStr.Trim() == "{}" || contentStr.Trim() == "[]") 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); await _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, "[N8nEvaluationService] n8n Webhook returned an EMPTY response for Request {RequestId}. Flagging as AI Rejection (Too Risky).", request.RequestId);
return CreateRejectionFallback(request, "Die KI (n8n/Gemini) stuft den Trade als zu riskant ein und empfiehlt keine Positionierung."); 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(); string jsonToDeserialize = contentStr.Trim();
if (jsonToDeserialize.StartsWith('[') && jsonToDeserialize.EndsWith(']')) if (jsonToDeserialize.StartsWith('[') && jsonToDeserialize.EndsWith(']'))
{ {
@@ -74,28 +79,28 @@ public class N8nEvaluationService : IN8nEvaluationService
if (responseDto != null && !string.IsNullOrWhiteSpace(responseDto.AiDecision)) 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}", await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[N8nEvaluationService] 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); request.RequestId, responseDto.AiDecision, responseDto.EvalScore, responseDto.SuggestedDirection, responseDto.SuggestedTimeframe);
return responseDto; return responseDto;
} }
} }
else else
{ {
_logger.LogWarning("[{Channel}] n8n Webhook returned HTTP {StatusCode} for Request {RequestId}", await _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, "[N8nEvaluationService] n8n Webhook returned HTTP {StatusCode} for Request {RequestId}",
"AnalyzerChannel", response.StatusCode, request.RequestId); response.StatusCode, request.RequestId);
} }
} }
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) 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); await _finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, ex, "[N8nEvaluationService] Timeout waiting for n8n AI Evaluation Webhook (Request {RequestId}). Consider increasing timeout.", request.RequestId);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "[{Channel}] Error calling n8n AI Evaluation Webhook for Request {RequestId}", "AnalyzerChannel", request.RequestId); await _finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, ex, "[N8nEvaluationService] Error calling n8n AI Evaluation Webhook for Request {RequestId}", request.RequestId);
} }
return null; // Signals RPC/Service failure to caller return null;
} }
private static N8nAnalysisResponseDto CreateRejectionFallback(N8nAnalysisRequestDto request, string reasoning) private static N8nAnalysisResponseDto CreateRejectionFallback(N8nAnalysisRequestDto request, string reasoning)
@@ -1,21 +1,22 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using FinlyticAnalyzer.Util;
using FinlyticCore.Dtos.News; using FinlyticCore.Dtos.News;
using FinlyticCore.Models.Analyzer; using FinlyticCore.Models.Analyzer;
using Microsoft.Extensions.Logging; using FinlyticCore.Services;
namespace FinlyticAnalyzer.Services; namespace FinlyticAnalyzer.Services;
public class ThreeLayerFilterEngine : IThreeLayerFilterEngine public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
{ {
private readonly ILogger<ThreeLayerFilterEngine> _logger; private readonly IFinlyticLogger<ThreeLayerFilterEngine> _finlyticLogger;
private readonly ConcurrentDictionary<string, DateTime> _seenEvents = new(); private readonly ConcurrentDictionary<string, DateTime> _seenEvents = new();
private readonly object _cleanupLock = new(); private readonly object _cleanupLock = new();
private DateTime _lastCleanupTime = DateTime.UtcNow; private DateTime _lastCleanupTime = DateTime.UtcNow;
public ThreeLayerFilterEngine(ILogger<ThreeLayerFilterEngine> logger) public ThreeLayerFilterEngine(IFinlyticLogger<ThreeLayerFilterEngine> finlyticLogger)
{ {
_logger = logger; _finlyticLogger = finlyticLogger;
} }
/// <summary> /// <summary>
@@ -25,9 +26,6 @@ public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
{ {
var result = new FilterResult(); var result = new FilterResult();
// -------------------------------------------------------------
// Layer 1: Relevance, ISIN & Deduplication
// -------------------------------------------------------------
if (newsEvent == null || newsEvent.Id == Guid.Empty) if (newsEvent == null || newsEvent.Id == Guid.Empty)
{ {
result.Passed = false; result.Passed = false;
@@ -38,7 +36,6 @@ public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
string eventId = newsEvent.Id.ToString(); string eventId = newsEvent.Id.ToString();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
// Safely clean up dictionary every 30 minutes (thread-safe lock)
if ((now - _lastCleanupTime).TotalMinutes > 30 || _seenEvents.Count > 10000) if ((now - _lastCleanupTime).TotalMinutes > 30 || _seenEvents.Count > 10000)
{ {
lock (_cleanupLock) lock (_cleanupLock)
@@ -50,7 +47,6 @@ public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
} }
} }
// Deduplication check (keep history for 12 hours)
if (_seenEvents.TryGetValue(eventId, out var prevTime) && (now - prevTime).TotalHours < 12.0) if (_seenEvents.TryGetValue(eventId, out var prevTime) && (now - prevTime).TotalHours < 12.0)
{ {
result.Passed = false; result.Passed = false;
@@ -63,7 +59,6 @@ public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
string isin = string.Empty; string isin = string.Empty;
string assetName = string.Empty; string assetName = string.Empty;
// Extract parameters strictly from MatchedAssets
if (newsEvent.MatchedAssets != null && newsEvent.MatchedAssets.Count > 0) if (newsEvent.MatchedAssets != null && newsEvent.MatchedAssets.Count > 0)
{ {
var firstAsset = newsEvent.MatchedAssets[0]; var firstAsset = newsEvent.MatchedAssets[0];
@@ -71,7 +66,6 @@ public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
assetName = !string.IsNullOrWhiteSpace(firstAsset.Name) ? firstAsset.Name.Trim() : string.Empty; assetName = !string.IsNullOrWhiteSpace(firstAsset.Name) ? firstAsset.Name.Trim() : string.Empty;
} }
// Mandatory check: Must have a valid ISIN
if (string.IsNullOrWhiteSpace(isin)) if (string.IsNullOrWhiteSpace(isin))
{ {
result.Passed = false; result.Passed = false;
@@ -80,13 +74,9 @@ public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
} }
result.Isin = isin; result.Isin = isin;
// Asset-Symbol fallback to ISIN, Name is mapped appropriately later
result.Symbol = isin; result.Symbol = isin;
result.Sector = "General"; // Will be enriched downstream via Fundamentals RPC if available result.Sector = "General";
// -------------------------------------------------------------
// Layer 2: Impact & Dynamic VIX Threshold
// -------------------------------------------------------------
double impactScore = newsEvent.Confidence ?? 0.75; double impactScore = newsEvent.Confidence ?? 0.75;
if (impactScore <= 0) impactScore = 0.75; if (impactScore <= 0) impactScore = 0.75;
@@ -106,14 +96,11 @@ public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
{ {
result.Passed = false; result.Passed = false;
result.RejectReason = $"Layer 2: Impact score ({impactScore:F2}) below dynamic VIX threshold ({requiredThreshold:F2}) for regime {regime}"; 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}", _ = _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ThreeLayerFilterEngine] Event {EventId} (ISIN: {Isin}) rejected by Layer 2 filter. Impact: {Impact:F2}, Threshold: {Threshold:F2}, Regime: {Regime}",
"AnalyzerChannel", eventId, isin, impactScore, requiredThreshold, regime); eventId, isin, impactScore, requiredThreshold, regime);
return result; return result;
} }
// -------------------------------------------------------------
// Layer 3: Dynamic Parameter & Risk Engine
// -------------------------------------------------------------
result.RiskTolerance = regime switch result.RiskTolerance = regime switch
{ {
VixMarketRegime.Panic => "Conservative", VixMarketRegime.Panic => "Conservative",
@@ -125,8 +112,8 @@ public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
result.InstrumentType = regime == VixMarketRegime.Panic ? "Option" : "Stock"; result.InstrumentType = regime == VixMarketRegime.Panic ? "Option" : "Stock";
result.Passed = true; result.Passed = true;
_logger.LogInformation("[{Channel}] Event {EventId} passed 3-Layer Filter for ISIN {Isin}. Impact: {Impact:F2}, Regime: {Regime}", _ = _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ThreeLayerFilterEngine] Event {EventId} passed 3-Layer Filter for ISIN {Isin}. Impact: {Impact:F2}, Regime: {Regime}",
"AnalyzerChannel", eventId, result.Isin, impactScore, regime); eventId, result.Isin, impactScore, regime);
return result; return result;
} }
+11 -12
View File
@@ -1,25 +1,26 @@
using System; using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FinlyticAnalyzer.Util;
using FinlyticCore.Models.Analyzer; using FinlyticCore.Models.Analyzer;
using FinlyticCore.Services;
using FinlyticCore.Services.Yahoo; using FinlyticCore.Services.Yahoo;
using Microsoft.Extensions.Logging;
namespace FinlyticAnalyzer.Services; namespace FinlyticAnalyzer.Services;
public class VixTrackerService : IVixTrackerService public class VixTrackerService : IVixTrackerService
{ {
private readonly YahooFinanceClient _yahooClient; private readonly YahooFinanceClient _yahooClient;
private readonly ILogger<VixTrackerService> _logger; private readonly IFinlyticLogger<VixTrackerService> _finlyticLogger;
private decimal _currentVix = 18.5m; // Default: Normal Regime private decimal _currentVix = 18.5m;
private VixMarketRegime _currentRegime = VixMarketRegime.Normal; private VixMarketRegime _currentRegime = VixMarketRegime.Normal;
private readonly object _lock = new(); private readonly object _lock = new();
public VixTrackerService(YahooFinanceClient yahooClient, ILogger<VixTrackerService> logger) public VixTrackerService(YahooFinanceClient yahooClient, IFinlyticLogger<VixTrackerService> finlyticLogger)
{ {
_yahooClient = yahooClient; _yahooClient = yahooClient;
_logger = logger; _finlyticLogger = finlyticLogger;
} }
public decimal GetCurrentVix() public decimal GetCurrentVix()
@@ -52,13 +53,13 @@ public class VixTrackerService : IVixTrackerService
if (oldRegime != _currentRegime) if (oldRegime != _currentRegime)
{ {
_logger.LogWarning("[{Channel}] [VIX REGIME SHIFT] Markt-Regime gewechselt: {OldRegime} -> {NewRegime} (VIX: {Vix:F2})", _ = _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, "[VixTrackerService] [VIX REGIME SHIFT] Markt-Regime gewechselt: {OldRegime} -> {NewRegime} (VIX: {Vix:F2})",
"AnalyzerChannel", oldRegime, _currentRegime, _currentVix); oldRegime, _currentRegime, _currentVix);
} }
else if (Math.Abs(oldVix - vixValue) >= 0.5m) else if (Math.Abs(oldVix - vixValue) >= 0.5m)
{ {
_logger.LogInformation("[{Channel}] VIX aktualisiert: {Vix:F2} (Regime: {Regime})", _ = _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[VixTrackerService] VIX aktualisiert: {Vix:F2} (Regime: {Regime})",
"AnalyzerChannel", _currentVix, _currentRegime); _currentVix, _currentRegime);
} }
} }
} }
@@ -77,12 +78,10 @@ public class VixTrackerService : IVixTrackerService
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{ {
// Graceful shutdown
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[{Channel}] Fehler beim Abfragen von ^VIX über YahooFinanceClient. Nutze gecachten Wert {Vix}.", await _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, ex, "[VixTrackerService] Fehler beim Abfragen von ^VIX über YahooFinanceClient. Nutze gecachten Wert {Vix}.", GetCurrentVix());
"AnalyzerChannel", GetCurrentVix());
} }
return GetCurrentVix(); return GetCurrentVix();
+13 -22
View File
@@ -3,15 +3,16 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
using FinlyticAnalyzer.Util;
using FinlyticCore.Models.Analyzer; using FinlyticCore.Models.Analyzer;
using FinlyticCore.Models.Trades; using FinlyticCore.Models.Trades;
using Microsoft.Extensions.Logging; using FinlyticCore.Services;
namespace FinlyticAnalyzer.Services; namespace FinlyticAnalyzer.Services;
public class WinRateCalculator : IWinRateCalculator public class WinRateCalculator : IWinRateCalculator
{ {
private readonly ILogger<WinRateCalculator> _logger; private readonly IFinlyticLogger<WinRateCalculator> _finlyticLogger;
private readonly string _feedbackDir; private readonly string _feedbackDir;
private readonly object _cacheLock = new(); private readonly object _cacheLock = new();
@@ -19,9 +20,9 @@ public class WinRateCalculator : IWinRateCalculator
private DateTime _lastCacheTime = DateTime.MinValue; private DateTime _lastCacheTime = DateTime.MinValue;
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(3); private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(3);
public WinRateCalculator(ILogger<WinRateCalculator> logger) public WinRateCalculator(IFinlyticLogger<WinRateCalculator> finlyticLogger)
{ {
_logger = logger; _finlyticLogger = finlyticLogger;
_feedbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "feedback"); _feedbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "feedback");
if (!Directory.Exists(_feedbackDir)) if (!Directory.Exists(_feedbackDir))
{ {
@@ -31,7 +32,6 @@ public class WinRateCalculator : IWinRateCalculator
/// <summary> /// <summary>
/// Calculates the win rate for a given sector and symbol under the specified market regime. /// Calculates the win rate for a given sector and symbol under the specified market regime.
/// Uses cached feedback records (3-minute TTL) to prevent disk I/O bottlenecks.
/// </summary> /// </summary>
public double CalculateWinRate(string sector, string symbol, VixMarketRegime regime) public double CalculateWinRate(string sector, string symbol, VixMarketRegime regime)
{ {
@@ -53,27 +53,23 @@ public class WinRateCalculator : IWinRateCalculator
{ {
try try
{ {
// 1. N8n AI Confidence Score (Weight: 40%)
double n8nComponent = 62.0; double n8nComponent = 62.0;
if (n8nEvalScore.HasValue && n8nEvalScore.Value > 0) if (n8nEvalScore.HasValue && n8nEvalScore.Value > 0)
{ {
n8nComponent = n8nEvalScore.Value <= 1.0 ? n8nEvalScore.Value * 100.0 : n8nEvalScore.Value; n8nComponent = n8nEvalScore.Value <= 1.0 ? n8nEvalScore.Value * 100.0 : n8nEvalScore.Value;
} }
// 2. Technical Score (Weight: 30%)
double taComponent = 60.0; double taComponent = 60.0;
if (technicalScore.HasValue && technicalScore.Value > 0) if (technicalScore.HasValue && technicalScore.Value > 0)
{ {
taComponent = technicalScore.Value <= 1.0 ? technicalScore.Value * 100.0 : technicalScore.Value; taComponent = technicalScore.Value <= 1.0 ? technicalScore.Value * 100.0 : technicalScore.Value;
} }
// 3. Sentiment Score (Weight: 15%)
double sentComponent = 58.0; double sentComponent = 58.0;
if (sentimentScore.HasValue) if (sentimentScore.HasValue)
{ {
if (sentimentScore.Value >= -1.0 && sentimentScore.Value <= 1.0) if (sentimentScore.Value >= -1.0 && sentimentScore.Value <= 1.0)
{ {
// Map sentiment from -1.0..+1.0 into 35.0..85.0
sentComponent = 50.0 + (sentimentScore.Value * 25.0); sentComponent = 50.0 + (sentimentScore.Value * 25.0);
} }
else else
@@ -82,29 +78,25 @@ public class WinRateCalculator : IWinRateCalculator
} }
} }
// 4. Fundamental Score (Weight: 15%)
double fundComponent = 60.0; double fundComponent = 60.0;
if (fundamentalScore.HasValue && fundamentalScore.Value > 0) if (fundamentalScore.HasValue && fundamentalScore.Value > 0)
{ {
fundComponent = fundamentalScore.Value <= 1.0 ? fundamentalScore.Value * 100.0 : fundamentalScore.Value; fundComponent = fundamentalScore.Value <= 1.0 ? fundamentalScore.Value * 100.0 : fundamentalScore.Value;
} }
// Multi-factor weighted composite
double composite = (n8nComponent * 0.40) + (taComponent * 0.30) + (sentComponent * 0.15) + (fundComponent * 0.15); double composite = (n8nComponent * 0.40) + (taComponent * 0.30) + (sentComponent * 0.15) + (fundComponent * 0.15);
// 5. Market Regime & Volatility Adjustment
double vixAdjustment = regime switch double vixAdjustment = regime switch
{ {
VixMarketRegime.LowVol => +4.0, // Calm trending market VixMarketRegime.LowVol => +4.0,
VixMarketRegime.Normal => +1.5, // Normal conditions VixMarketRegime.Normal => +1.5,
VixMarketRegime.HighVol => -3.5, // Increased whipsaws VixMarketRegime.HighVol => -3.5,
VixMarketRegime.Panic => -8.0, // High panic / uncertainty VixMarketRegime.Panic => -8.0,
_ => 0.0 _ => 0.0
}; };
composite += vixAdjustment; composite += vixAdjustment;
// 6. Historical track record calibration (if available in feedback records)
var records = GetCachedOrLoadRecords(); var records = GetCachedOrLoadRecords();
if (records.Count > 0) if (records.Count > 0)
{ {
@@ -120,17 +112,16 @@ public class WinRateCalculator : IWinRateCalculator
} }
} }
// Clamp between realistic financial statistical bounds (45.0% to 92.0%)
double finalWinRate = Math.Clamp(Math.Round(composite, 1), 45.0, 92.0); double finalWinRate = Math.Clamp(Math.Round(composite, 1), 45.0, 92.0);
_logger.LogInformation("[{Channel}] Dynamic Win-Rate for {Symbol} ({Sector}): {WinRate:F1}% [AI: {N8n:F1}%, TA: {TA:F1}%, Sent: {Sent:F1}%, Regime: {Regime}]", _ = _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[WinRateCalculator] Dynamic Win-Rate for {Symbol} ({Sector}): {WinRate:F1}% [AI: {N8n:F1}%, TA: {TA:F1}%, Sent: {Sent:F1}%, Regime: {Regime}]",
"AnalyzerChannel", symbol, sector, finalWinRate, n8nComponent, taComponent, sentComponent, regime); symbol, sector, finalWinRate, n8nComponent, taComponent, sentComponent, regime);
return finalWinRate; return finalWinRate;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[{Channel}] Error calculating dynamic win-rate for {Symbol}. Fallback applied.", "AnalyzerChannel", symbol); _ = _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, ex, "[WinRateCalculator] Error calculating dynamic win-rate for {Symbol}. Fallback applied.", symbol);
return 65.0; return 65.0;
} }
} }
@@ -162,7 +153,7 @@ public class WinRateCalculator : IWinRateCalculator
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[{Channel}] Failed to read or parse feedback file '{File}'", "AnalyzerChannel", file); _ = _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, ex, "[WinRateCalculator] Failed to read or parse feedback file '{File}'", file);
} }
} }
} }
+168 -96
View File
@@ -8,9 +8,11 @@ using FinlyticAnalyzer.Database;
using FinlyticAnalyzer.Entities; using FinlyticAnalyzer.Entities;
using FinlyticAnalyzer.Services; using FinlyticAnalyzer.Services;
using FinlyticCore.Dtos; using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Settings;
using FinlyticCore.Models; using FinlyticCore.Models;
using FinlyticCore.Models.Analyzer; using FinlyticCore.Models.Analyzer;
using FinlyticCore.Models.Trades; using FinlyticCore.Models.Trades;
using FinlyticCore.Services;
using FinlyticCore.Util; using FinlyticCore.Util;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
@@ -64,19 +66,19 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
ClientId = $"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "finlytic_analyzer")}_{Guid.NewGuid():N}" ClientId = $"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "finlytic_analyzer")}_{Guid.NewGuid():N}"
}; };
_logger.LogInformation("[{Channel}] Starting Unified Analyzer MQTT Client. Host: {Host}, ClientId: {ClientId}", "AnalyzerChannel", config.Host, config.ClientId); _logger.LogInformation("Starting Unified Analyzer MQTT Client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
await ConnectAsync(config); await ConnectAsync(config);
} }
public async Task StopAsync(CancellationToken cancellationToken) public async Task StopAsync(CancellationToken cancellationToken)
{ {
_logger.LogInformation("[{Channel}] Stopping Unified Analyzer MQTT Client.", "AnalyzerChannel"); _logger.LogInformation("Stopping Unified Analyzer MQTT Client.");
await DisconnectAsync(); await DisconnectAsync();
} }
protected override async Task OnConnectedAsync() protected override async Task OnConnectedAsync()
{ {
_logger.LogInformation("[{Channel}] Analyzer MQTT Client connected. Subscribing to topics and RPC response channels...", "AnalyzerChannel"); _logger.LogInformation("Analyzer MQTT Client connected. Subscribing to topics and RPC response channels...");
// Incoming Event Topics // Incoming Event Topics
await SubscribeAsync("services/news/#"); await SubscribeAsync("services/news/#");
@@ -85,16 +87,29 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
await SubscribeAsync("services/config/updated/#"); await SubscribeAsync("services/config/updated/#");
await SubscribeAsync("services/request/health_Ping/#"); await SubscribeAsync("services/request/health_Ping/#");
await SubscribeAsync("services/request/analyzer_TriggerManual/#"); await SubscribeAsync("services/request/analyzer_TriggerManual/#");
await SubscribeAsync("services/request/analyzer_settings_GetAll/#");
await SubscribeAsync("services/request/analyzer_settings_Update/#");
await SubscribeAsync("finlytic/trades/closed/#"); await SubscribeAsync("finlytic/trades/closed/#");
// RPC Response Channels // RPC Response Channels
await SubscribeAsync("services/response/ta_GetAnalysis/#"); await SubscribeAsync("services/response/ta_GetAnalysis/#");
await SubscribeAsync("services/response/fundamentals_Get/#"); await SubscribeAsync("services/response/fundamentals_Get/#");
await SubscribeAsync("services/response/sentiment_GetIsin/#"); await SubscribeAsync("services/response/sentiment_GetIsin/#");
await SubscribeAsync("services/response/sentiment_Analyze/#");
await SubscribeAsync("services/response/trades_Get/#"); await SubscribeAsync("services/response/trades_Get/#");
await SubscribeAsync("services/response/tr_GetLivePrice/#"); await SubscribeAsync("services/response/tr_GetLivePrice/#");
await SubscribeAsync("services/response/events_GetByMonth/#");
await SubscribeAsync("services/response/events_GetAll/#");
_logger.LogInformation("[{Channel}] Successfully subscribed to all event and RPC channels.", "AnalyzerChannel"); FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) =>
{
if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticAnalyzer", StringComparison.OrdinalIgnoreCase))
{
await PublishAsync("finlytic/logs/FinlyticAnalyzer", logDto);
}
};
_logger.LogInformation("Successfully subscribed to all event and RPC channels.");
} }
protected override async Task OnMessageReceivedAsync(string topic, string payloadStr) protected override async Task OnMessageReceivedAsync(string topic, string payloadStr)
@@ -114,10 +129,9 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
string respTopic = $"services/response/health_Ping/{correlationId}"; string respTopic = $"services/response/health_Ping/{correlationId}";
var healthResp = new ServiceHealthResponse("FinlyticAnalyzer", "Online", DateTime.UtcNow, "Connected"); var healthResp = new ServiceHealthResponse("FinlyticAnalyzer", "Online", DateTime.UtcNow, "Connected");
await PublishAsync(respTopic, healthResp); await PublishAsync(respTopic, healthResp);
if (LogCategoryFilter.IsEnabled(LogCategory.MqttHealthPing)) using var scope = _scopeFactory.CreateScope();
{ var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
_logger.LogInformation("[{Channel}] [AnalyzerMqttClient] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", "AnalyzerChannel", correlationId); await finlyticLogger.LogInfoAsync(SettingKeys.HealthPingChannel, "[AnalyzerMqttClient] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", correlationId);
}
} }
return; return;
} }
@@ -126,21 +140,22 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
{ {
if (topic.EndsWith("FinlyticAnalyzer", StringComparison.OrdinalIgnoreCase)) if (topic.EndsWith("FinlyticAnalyzer", StringComparison.OrdinalIgnoreCase))
{ {
_logger.LogInformation("[{Channel}] [AnalyzerMqttClient] Received config update event for FinlyticAnalyzer.", "AnalyzerChannel");
try try
{ {
var configUpdate = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.ServiceConfigUpdatePayload); var configUpdate = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.ServiceConfigUpdatePayload);
if (configUpdate?.Settings != null && configUpdate.Settings.Count > 0) if (configUpdate?.Settings != null && configUpdate.Settings.Count > 0)
{ {
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var settingsDb = scope.ServiceProvider.GetRequiredService<ISettingsDbService>(); var settings = scope.ServiceProvider.GetRequiredService<ISettingsService>();
await settingsDb.UpdateSettingsFromDictionaryAsync(configUpdate.Settings); var dict = configUpdate.Settings.ToDictionary(k => k.Key, v => (object?)v.Value);
_logger.LogInformation("[{Channel}] [AnalyzerMqttClient] Persisted {Count} updated settings to FinlyticAnalyzer database.", "AnalyzerChannel", configUpdate.Settings.Count); await settings.UpdateSettingsAsync(dict);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "[{Channel}] [AnalyzerMqttClient] Error processing MQTT config update event.", "AnalyzerChannel"); using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
await finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, ex, "[AnalyzerMqttClient] Error processing MQTT config update event.");
} }
} }
return; return;
@@ -160,6 +175,16 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
var correlationId = topic.Split('/').Last(); var correlationId = topic.Split('/').Last();
await HandleManualTriggerAsync(correlationId, payloadStr, CancellationToken.None); await HandleManualTriggerAsync(correlationId, payloadStr, CancellationToken.None);
} }
else if (topic.StartsWith("services/request/analyzer_settings_GetAll", StringComparison.OrdinalIgnoreCase))
{
var correlationId = topic.Split('/').Last();
await HandleSettingsGetAllAsync(correlationId);
}
else if (topic.StartsWith("services/request/analyzer_settings_Update", StringComparison.OrdinalIgnoreCase))
{
var correlationId = topic.Split('/').Last();
await HandleSettingsUpdateAsync(payloadStr, correlationId);
}
else if (topic.StartsWith("finlytic/trades/closed/")) else if (topic.StartsWith("finlytic/trades/closed/"))
{ {
await HandleClosedTradeFeedbackAsync(payloadStr); await HandleClosedTradeFeedbackAsync(payloadStr);
@@ -167,12 +192,80 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "[{Channel}] Error processing incoming MQTT message on topic {Topic}", "AnalyzerChannel", topic); using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
await finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, ex, "[AnalyzerMqttClient] Error processing incoming MQTT message on topic {Topic}", topic);
}
}
private async Task HandleSettingsGetAllAsync(string correlationId)
{
using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticAnalyzer] [Settings_GetAll] Retrieving all dynamic settings via reflection [CorrelationId: {CorrelationId}]", correlationId);
try
{
var settings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
var responseTopic = $"services/response/analyzer_settings_GetAll/{correlationId}";
await PublishAsync(responseTopic, settings);
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticAnalyzer] [Settings_GetAll] Published {Count} settings to '{ResponseTopic}'", settings.Count, responseTopic);
}
catch (Exception ex)
{
await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticAnalyzer] [Settings_GetAll] Failed to retrieve settings.");
}
}
private async Task HandleSettingsUpdateAsync(string payload, string correlationId)
{
if (string.IsNullOrWhiteSpace(payload)) return;
using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticAnalyzer] [Settings_Update] Processing settings update RPC [CorrelationId: {CorrelationId}]", correlationId);
try
{
Dictionary<string, object?>? updates = null;
try
{
updates = JsonSerializer.Deserialize<Dictionary<string, object?>>(payload);
}
catch
{
var list = JsonSerializer.Deserialize<List<DynamicSettingDto>>(payload);
if (list != null)
{
updates = new Dictionary<string, object?>();
foreach (var item in list) updates[item.Key] = item.Value;
}
}
if (updates != null && updates.Count > 0)
{
await settingsService.UpdateSettingsAsync(updates);
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticAnalyzer] [Settings_Update] Successfully updated {Count} settings in database and cache.", updates.Count);
}
var currentSettings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
var responseTopic = $"services/response/analyzer_settings_Update/{correlationId}";
await PublishAsync(responseTopic, currentSettings);
}
catch (Exception ex)
{
await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticAnalyzer] [Settings_Update] Failed to update settings.");
} }
} }
private async Task HandleClosedTradeFeedbackAsync(string payloadStr) private async Task HandleClosedTradeFeedbackAsync(string payloadStr)
{ {
using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
try try
{ {
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
@@ -209,32 +302,31 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
string filePath = System.IO.Path.Combine(feedbackDir, $"{closedDto.TradeId}.json"); string filePath = System.IO.Path.Combine(feedbackDir, $"{closedDto.TradeId}.json");
await System.IO.File.WriteAllTextAsync(filePath, JsonSerializer.Serialize(new[] { feedback }, options)); await System.IO.File.WriteAllTextAsync(filePath, JsonSerializer.Serialize(new[] { feedback }, options));
_logger.LogInformation("[{Channel}] Processed closed trade feedback for {TradeId}. Saved to {FilePath}", "AnalyzerChannel", closedDto.TradeId, filePath); await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[AnalyzerMqttClient] Processed closed trade feedback for {TradeId}. Saved to {FilePath}", closedDto.TradeId, filePath);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "[{Channel}] Error processing closed trade feedback.", "AnalyzerChannel"); await finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, ex, "[AnalyzerMqttClient] Error processing closed trade feedback.");
} }
} }
private async Task HandleManualTriggerAsync(string correlationId, string payloadStr, CancellationToken cancellationToken) private async Task HandleManualTriggerAsync(string correlationId, string payloadStr, CancellationToken cancellationToken)
{ {
using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
try try
{ {
var manualReq = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.ManualAnalysisRpcRequest); var manualReq = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.ManualAnalysisRpcRequest);
if (manualReq == null || string.IsNullOrWhiteSpace(manualReq.Isin)) if (manualReq == null || string.IsNullOrWhiteSpace(manualReq.Isin))
{ {
_logger.LogWarning("[{Channel}] Manual trigger received without valid request or ISIN.", "AnalyzerChannel"); await finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, "[AnalyzerMqttClient] Manual trigger received without valid request or ISIN.");
return; return;
} }
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerManual)) await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ManualAnalyzer] [TRIGGERED] Processing rich manual trigger for ISIN '{Isin}' (Symbol: {Symbol}). CorrelationId: {CorrelationId}", manualReq.Isin, manualReq.Symbol, correlationId);
{
_logger.LogInformation("[{Channel}] [ManualAnalyzer] [TRIGGERED] Processing rich manual trigger for ISIN '{Isin}' (Symbol: {Symbol}). CorrelationId: {CorrelationId}", "AnalyzerChannel", manualReq.Isin, manualReq.Symbol, correlationId);
}
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AnalyzerDbContext>(); var dbContext = scope.ServiceProvider.GetRequiredService<AnalyzerDbContext>();
var regime = _vixTracker.GetCurrentRegime(); var regime = _vixTracker.GetCurrentRegime();
@@ -325,9 +417,8 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
var n8nResponse = await _n8nService.EvaluateAssetAsync(n8nRequest, cancellationToken); var n8nResponse = await _n8nService.EvaluateAssetAsync(n8nRequest, cancellationToken);
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>(); var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
var settings = await settingsService.GetSettingsAsync(); double minSignalScore = await settingsService.GetSettingAsync(SettingKeys.MinWinRateThreshold, cancellationToken);
double minSignalScore = settings.MinSignalScore;
double dynamicWinRate = _winRateCalculator.CalculateDynamicWinRate( double dynamicWinRate = _winRateCalculator.CalculateDynamicWinRate(
manualReq.Sector, manualReq.Sector,
@@ -422,12 +513,12 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
{ {
string propTopic = $"finlytic/trades/proposed/{(string.IsNullOrWhiteSpace(manualReq.Sector) ? "general" : manualReq.Sector.ToLowerInvariant())}/{manualReq.Symbol.ToLowerInvariant()}"; string propTopic = $"finlytic/trades/proposed/{(string.IsNullOrWhiteSpace(manualReq.Sector) ? "general" : manualReq.Sector.ToLowerInvariant())}/{manualReq.Symbol.ToLowerInvariant()}";
await PublishAsync(propTopic, proposalDto); await PublishAsync(propTopic, proposalDto);
_logger.LogInformation("[{Channel}] [ManualAnalyzer] [DISPATCHED] Dispatched Manual Trade Proposal {AnalysisId} to topic {Topic}", "AnalyzerChannel", analysisId, propTopic); await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ManualAnalyzer] [DISPATCHED] Dispatched Manual Trade Proposal {AnalysisId} to topic {Topic}", analysisId, propTopic);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "[{Channel}] Failed to handle manual trigger for correlation {CorrelationId}.", "AnalyzerChannel", correlationId); await finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, ex, "[AnalyzerMqttClient] Failed to handle manual trigger for correlation {CorrelationId}.", correlationId);
try try
{ {
var errorResponse = new ManualAnalysisResponseDto var errorResponse = new ManualAnalysisResponseDto
@@ -439,7 +530,7 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
} }
catch (Exception pubEx) catch (Exception pubEx)
{ {
_logger.LogError(pubEx, "[{Channel}] Failed to publish error response for correlation {CorrelationId}.", "AnalyzerChannel", correlationId); await finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, pubEx, "[AnalyzerMqttClient] Failed to publish error response for correlation {CorrelationId}.", correlationId);
} }
} }
} }
@@ -458,13 +549,16 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[{Channel}] Failed to parse VIX tick message.", "AnalyzerChannel"); _logger.LogWarning(ex, "Failed to parse VIX tick message.");
} }
} }
} }
private async Task ProcessNewsMessageAsync(string payloadStr, CancellationToken cancellationToken) private async Task ProcessNewsMessageAsync(string payloadStr, CancellationToken cancellationToken)
{ {
using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
var newsArticle = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.NewsArticleDto); var newsArticle = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.NewsArticleDto);
if (newsArticle == null) return; if (newsArticle == null) return;
@@ -474,17 +568,11 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
var filterResult = _filterEngine.EvaluateNews(newsArticle, regime); var filterResult = _filterEngine.EvaluateNews(newsArticle, regime);
if (!filterResult.Passed) if (!filterResult.Passed)
{ {
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerAuto)) await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[AutoScreener] [SKIPPED] News message skipped for ISIN '{Isin}'. Reason: {Reason}", filterResult.Isin, filterResult.RejectReason);
{
_logger.LogInformation("[{Channel}] [AutoScreener] [SKIPPED] News message skipped for ISIN '{Isin}'. Reason: {Reason}", "AnalyzerChannel", filterResult.Isin, filterResult.RejectReason);
}
return; return;
} }
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerAuto)) await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[AutoScreener] [TRIGGERED] Screening market opportunity for ISIN '{Isin}'...", filterResult.Isin);
{
_logger.LogInformation("[{Channel}] [AutoScreener] [TRIGGERED] Screening market opportunity for ISIN '{Isin}'...", "AnalyzerChannel", filterResult.Isin);
}
string analysisId = Guid.NewGuid().ToString("N"); string analysisId = Guid.NewGuid().ToString("N");
string eventId = newsArticle.Id != Guid.Empty ? newsArticle.Id.ToString() : analysisId; string eventId = newsArticle.Id != Guid.Empty ? newsArticle.Id.ToString() : analysisId;
@@ -543,7 +631,6 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
{ {
var isinReq = new IsinRequest(filterResult.Isin); var isinReq = new IsinRequest(filterResult.Isin);
// Parallel RPC calls (was sequential — up to 12s latency reduced to ~3s)
var livePriceTask = SendRpcRequestAsync<FinlyticCore.Dtos.TechnicalAnalysis.LivePriceDto, IsinRequest>( var livePriceTask = SendRpcRequestAsync<FinlyticCore.Dtos.TechnicalAnalysis.LivePriceDto, IsinRequest>(
"tr_GetLivePrice", isinReq, TimeSpan.FromSeconds(5)); "tr_GetLivePrice", isinReq, TimeSpan.FromSeconds(5));
var taTask = SendRpcRequestAsync<FinlyticCore.Dtos.TechnicalAnalysis.TechnicalAnalysisDto, IsinRequest>( var taTask = SendRpcRequestAsync<FinlyticCore.Dtos.TechnicalAnalysis.TechnicalAnalysisDto, IsinRequest>(
@@ -606,7 +693,6 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
if (sentResp != null) if (sentResp != null)
{ {
double compound = sentResp.CurrentSummary?.CompoundScore ?? 0.0; double compound = sentResp.CurrentSummary?.CompoundScore ?? 0.0;
// FinBERT compound score is in range [-1.0, +1.0]. Normalize to [0.0, 1.0] for AI prompt context
double normalizedScore = Math.Clamp((compound + 1.0) / 2.0, 0.0, 1.0); double normalizedScore = Math.Clamp((compound + 1.0) / 2.0, 0.0, 1.0);
sentInfo = new SentimentContextInfo sentInfo = new SentimentContextInfo
@@ -620,7 +706,7 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "[{Channel}] Failed to fetch context data for auto screener analysis.", "AnalyzerChannel"); await finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, ex, "[AnalyzerMqttClient] Failed to fetch context data for auto screener analysis.");
} }
var n8nRequest = new N8nAnalysisRequestDto var n8nRequest = new N8nAnalysisRequestDto
@@ -670,13 +756,8 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
var n8nResponse = await _n8nService.EvaluateAssetAsync(n8nRequest, cancellationToken); var n8nResponse = await _n8nService.EvaluateAssetAsync(n8nRequest, cancellationToken);
double minSignalScore = 75.0; var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
using (var scope = _scopeFactory.CreateScope()) double minSignalScore = await settingsService.GetSettingAsync(SettingKeys.MinWinRateThreshold, cancellationToken);
{
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
var settings = await settingsService.GetSettingsAsync();
minSignalScore = settings.MinSignalScore;
}
double confidenceScore = n8nResponse?.EvalScore > 0 ? n8nResponse.EvalScore : 0.75; double confidenceScore = n8nResponse?.EvalScore > 0 ? n8nResponse.EvalScore : 0.75;
bool isHighConviction = n8nResponse != null && bool isHighConviction = n8nResponse != null &&
@@ -752,47 +833,44 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
sentimentScore: sentResp?.CurrentSummary?.CompoundScore, sentimentScore: sentResp?.CurrentSummary?.CompoundScore,
signalType: n8nResponse?.SuggestedDirection ?? "BUY"); signalType: n8nResponse?.SuggestedDirection ?? "BUY");
using (var scope = _scopeFactory.CreateScope()) var dbContext = scope.ServiceProvider.GetRequiredService<AnalyzerDbContext>();
bool hasRecentProposal = await dbContext.Analyses.AnyAsync(a =>
a.Isin == filterResult.Isin &&
a.IsTradeProposed &&
a.CreatedAt >= DateTime.UtcNow.AddHours(-4),
cancellationToken);
if (hasRecentProposal && isHighConviction)
{ {
var dbContext = scope.ServiceProvider.GetRequiredService<AnalyzerDbContext>(); await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[AutoScreener] Asset {Symbol} ({Isin}) already has an active trade proposal in the last 4 hours. Skipping duplicate trade proposal generation.",
finalSymbol, filterResult.Isin);
bool hasRecentProposal = await dbContext.Analyses.AnyAsync(a => isHighConviction = false;
a.Isin == filterResult.Isin &&
a.IsTradeProposed &&
a.CreatedAt >= DateTime.UtcNow.AddHours(-4),
cancellationToken);
if (hasRecentProposal && isHighConviction)
{
_logger.LogInformation("[{Channel}] [AutoScreener] Asset {Symbol} ({Isin}) already has an active trade proposal in the last 4 hours. Skipping duplicate trade proposal generation.",
"AnalyzerChannel", finalSymbol, filterResult.Isin);
isHighConviction = false;
}
var analysisEntity = new AnalysisEntity
{
AnalysisId = analysisId,
EventId = eventId,
Sector = filterResult.Sector,
Symbol = finalSymbol,
Isin = filterResult.Isin,
VixRegime = regime,
VixValue = currentVix,
ImpactScore = filterResult.ImpactScore,
WinRate = dynamicWinRate,
RawDataJson = payloadStr,
AiOutputJson = JsonSerializer.Serialize(recommendation),
N8nResponseJson = n8nResponse != null ? JsonSerializer.Serialize(n8nResponse) : "{}",
N8nEvalScore = n8nResponse?.EvalScore ?? 0,
N8nDecision = n8nResponse?.AiDecision ?? "None",
IsTradeProposed = isHighConviction,
CreatedAt = DateTime.UtcNow
};
dbContext.Analyses.Add(analysisEntity);
await dbContext.SaveChangesAsync(cancellationToken);
} }
var analysisEntity = new AnalysisEntity
{
AnalysisId = analysisId,
EventId = eventId,
Sector = filterResult.Sector,
Symbol = finalSymbol,
Isin = filterResult.Isin,
VixRegime = regime,
VixValue = currentVix,
ImpactScore = filterResult.ImpactScore,
WinRate = dynamicWinRate,
RawDataJson = payloadStr,
AiOutputJson = JsonSerializer.Serialize(recommendation),
N8nResponseJson = n8nResponse != null ? JsonSerializer.Serialize(n8nResponse) : "{}",
N8nEvalScore = n8nResponse?.EvalScore ?? 0,
N8nDecision = n8nResponse?.AiDecision ?? "None",
IsTradeProposed = isHighConviction,
CreatedAt = DateTime.UtcNow
};
dbContext.Analyses.Add(analysisEntity);
await dbContext.SaveChangesAsync(cancellationToken);
if (isHighConviction && n8nResponse != null) if (isHighConviction && n8nResponse != null)
{ {
var autoProposalDto = new TradeProposalDto var autoProposalDto = new TradeProposalDto
@@ -830,7 +908,7 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
string propTopic = $"finlytic/trades/proposed/{(string.IsNullOrWhiteSpace(filterResult.Sector) ? "general" : filterResult.Sector.ToLowerInvariant())}/{finalSymbol.ToLowerInvariant()}"; string propTopic = $"finlytic/trades/proposed/{(string.IsNullOrWhiteSpace(filterResult.Sector) ? "general" : filterResult.Sector.ToLowerInvariant())}/{finalSymbol.ToLowerInvariant()}";
await PublishAsync(propTopic, autoProposalDto); await PublishAsync(propTopic, autoProposalDto);
_logger.LogInformation("[{Channel}] [AutoScreener] Dispatched High-Conviction Proposal {TradeId} to topic {Topic}", "AnalyzerChannel", autoProposalDto.TradeId, propTopic); await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[AutoScreener] Dispatched High-Conviction Proposal {TradeId} to topic {Topic}", autoProposalDto.TradeId, propTopic);
} }
if (isHighConviction) if (isHighConviction)
@@ -839,19 +917,13 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
await PublishAsync(recTopic, recommendation); await PublishAsync(recTopic, recommendation);
await PublishAsync("finlytic/recommendations/auto", recommendation); await PublishAsync("finlytic/recommendations/auto", recommendation);
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerAuto)) await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[AutoScreener] [RECOMMENDED] High-Conviction Opportunity found for {Symbol} (Bias: {Bias}, Confidence: {Score:F2}). Published to {Topic}",
{ finalSymbol, recommendation.RecommendedAsset.Bias, recommendation.RecommendedAsset.ConfidenceScore, recTopic);
_logger.LogInformation("[{Channel}] [AutoScreener] [RECOMMENDED] High-Conviction Opportunity found for {Symbol} (Bias: {Bias}, Confidence: {Score:F2}). Published to {Topic}",
"AnalyzerChannel", finalSymbol, recommendation.RecommendedAsset.Bias, recommendation.RecommendedAsset.ConfidenceScore, recTopic);
}
} }
else else
{ {
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerAuto)) await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[AutoScreener] [DROPPED] Low-conviction signal for {Symbol} dropped (Confidence: {Score:F2}, Action: NO_ACTION)",
{ finalSymbol, recommendation.RecommendedAsset.ConfidenceScore);
_logger.LogInformation("[{Channel}] [AutoScreener] [DROPPED] Low-conviction signal for {Symbol} dropped (Confidence: {Score:F2}, Action: NO_ACTION)",
"AnalyzerChannel", finalSymbol, recommendation.RecommendedAsset.ConfidenceScore);
}
} }
} }
+30
View File
@@ -0,0 +1,30 @@
using FinlyticCore.Models.Settings;
namespace FinlyticAnalyzer.Util;
public static class SettingKeys
{
// --- Logging-Kanäle ---
public static readonly SettingKey<bool> AnalyzerChannel = new("Logging.Channel.Analyzer", true);
public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true);
public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true);
// --- Makro & VIX Schwellenwerte ---
public static readonly SettingKey<double> VixPanicThreshold = new("Macro.VixPanicThreshold", 28.0);
public static readonly SettingKey<double> VixElevatedThreshold = new("Macro.VixElevatedThreshold", 20.0);
public static readonly SettingKey<int> VixPollIntervalSeconds = new("Macro.VixPollIntervalSeconds", 60);
// --- Filter & Winrate-Logik ---
public static readonly SettingKey<double> MinWinRateThreshold = new("Filter.MinWinRateThreshold", 60.0);
public static readonly SettingKey<double> WeightMacro = new("Filter.WeightMacro", 0.30);
public static readonly SettingKey<double> WeightFundamental = new("Filter.WeightFundamental", 0.30);
public static readonly SettingKey<double> WeightSentiment = new("Filter.WeightSentiment", 0.20);
public static readonly SettingKey<double> WeightTechnical = new("Filter.WeightTechnical", 0.20);
// --- Trade & Risiko-Parameter ---
public static readonly SettingKey<double> DefaultTakeProfitPercent = new("Trade.DefaultTakeProfitPercent", 15.0);
public static readonly SettingKey<double> DefaultStopLossPercent = new("Trade.DefaultStopLossPercent", 5.0);
public static readonly SettingKey<int> MaxAllowedLeverage = new("Trade.MaxAllowedLeverage", 10);
public static readonly SettingKey<double> MaxRiskPerTradePercent = new("Trade.MaxRiskPerTradePercent", 2.0);
public static readonly SettingKey<int> ProposalValidityHours = new("Trade.ProposalValidityHours", 24);
}