feat(analyzer): dynamic settings, IFinlyticLogger, live log streaming, and EF migration
This commit is contained in:
@@ -5,11 +5,12 @@ using System.Threading.Tasks;
|
||||
using FinlyticAnalyzer.Database;
|
||||
using FinlyticAnalyzer.Entities;
|
||||
using FinlyticAnalyzer.Services;
|
||||
using FinlyticAnalyzer.Util;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Models.Trades;
|
||||
using FinlyticCore.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticAnalyzer.Controllers;
|
||||
|
||||
@@ -36,20 +37,20 @@ public class ManualAnalysisController : ControllerBase
|
||||
private readonly IN8nEvaluationService _n8nService;
|
||||
private readonly IWinRateCalculator _winRateCalculator;
|
||||
private readonly AnalyzerDbContext _dbContext;
|
||||
private readonly ILogger<ManualAnalysisController> _logger;
|
||||
private readonly IFinlyticLogger<ManualAnalysisController> _finlyticLogger;
|
||||
|
||||
public ManualAnalysisController(
|
||||
IVixTrackerService vixTracker,
|
||||
IN8nEvaluationService n8nService,
|
||||
IWinRateCalculator winRateCalculator,
|
||||
AnalyzerDbContext dbContext,
|
||||
ILogger<ManualAnalysisController> logger)
|
||||
IFinlyticLogger<ManualAnalysisController> finlyticLogger)
|
||||
{
|
||||
_vixTracker = vixTracker;
|
||||
_n8nService = n8nService;
|
||||
_winRateCalculator = winRateCalculator;
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
_finlyticLogger = finlyticLogger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -181,6 +182,8 @@ public class ManualAnalysisController : ControllerBase
|
||||
_dbContext.Analyses.Add(analysisEntity);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ManualAnalysisController] Manual analysis completed for {Symbol} (TradeProposed: {Proposed})", request.Symbol, shouldProceed);
|
||||
|
||||
if (!shouldProceed)
|
||||
{
|
||||
return Ok(new
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using FinlyticAnalyzer.Entities;
|
||||
using FinlyticCore.Database;
|
||||
using FinlyticCore.Entities.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace FinlyticAnalyzer.Database;
|
||||
|
||||
public class AnalyzerDbContext : DbContext
|
||||
public class AnalyzerDbContext : DbContext, ISettingsDbContext
|
||||
{
|
||||
public AnalyzerDbContext(DbContextOptions<AnalyzerDbContext> options) : base(options) { }
|
||||
|
||||
@@ -20,7 +22,7 @@ public class AnalyzerDbContext : DbContext
|
||||
modelBuilder.Entity<SettingEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.Key);
|
||||
entity.HasIndex(e => e.Key).IsUnique();
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,24 @@ using System;
|
||||
using FinlyticAnalyzer.Database;
|
||||
using FinlyticAnalyzer.Services;
|
||||
using FinlyticAnalyzer.Util;
|
||||
using FinlyticCore.Database;
|
||||
using FinlyticCore.Services;
|
||||
using FinlyticCore.Services.Yahoo;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
// Register DB Context
|
||||
builder.Services.AddDbContext<AnalyzerDbContext>(options =>
|
||||
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)
|
||||
builder.Services.AddHttpClient<IN8nEvaluationService, N8nEvaluationService>();
|
||||
@@ -38,14 +48,10 @@ using (var scope = host.Services.CreateScope())
|
||||
var context = scope.ServiceProvider.GetRequiredService<AnalyzerDbContext>();
|
||||
await context.Database.MigrateAsync();
|
||||
Console.WriteLine("Database migrations successfully executed for FinlyticAnalyzer.");
|
||||
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
|
||||
await settingsService.GetSettingsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
|
||||
logger.LogError(ex, "An error occurred during database migration for FinlyticAnalyzer on startup.");
|
||||
Console.WriteLine($"Critical error during database migration for FinlyticAnalyzer: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,30 +9,32 @@ using FinlyticCore.Dtos;
|
||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Models.Trades;
|
||||
using FinlyticCore.Services;
|
||||
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 IFinlyticLogger<ActiveTradeMonitorWorker> _finlyticLogger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly AnalyzerMqttClient _mqttClient;
|
||||
|
||||
public ActiveTradeMonitorWorker(ILogger<ActiveTradeMonitorWorker> logger, IServiceScopeFactory scopeFactory,
|
||||
public ActiveTradeMonitorWorker(
|
||||
IFinlyticLogger<ActiveTradeMonitorWorker> finlyticLogger,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
AnalyzerMqttClient mqttClient)
|
||||
{
|
||||
_logger = logger;
|
||||
_finlyticLogger = finlyticLogger;
|
||||
_scopeFactory = scopeFactory;
|
||||
_mqttClient = mqttClient;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] ActiveTradeMonitorWorker started.", "AnalyzerChannel");
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] ActiveTradeMonitorWorker started.");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -51,7 +53,7 @@ public class ActiveTradeMonitorWorker : BackgroundService
|
||||
}
|
||||
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
|
||||
@@ -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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
// 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"),
|
||||
@@ -93,13 +93,11 @@ public class ActiveTradeMonitorWorker : BackgroundService
|
||||
|
||||
if (trades.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] No active or proposed global trades found to monitor.",
|
||||
"AnalyzerChannel");
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] No active or proposed global trades found to monitor.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("[{Channel}] Found {Count} trades to monitor. Starting evaluation...", "AnalyzerChannel",
|
||||
trades.Count);
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] Found {Count} trades to monitor. Starting evaluation...", trades.Count);
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var n8nService = scope.ServiceProvider.GetRequiredService<IN8nEvaluationService>();
|
||||
@@ -115,8 +113,7 @@ public class ActiveTradeMonitorWorker : BackgroundService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Failed to monitor trade {TradeId} ({Symbol}).", "AnalyzerChannel",
|
||||
trade.TradeId, trade.Symbol);
|
||||
await _finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, ex, "[ActiveTradeMonitorWorker] Failed to monitor trade {TradeId} ({Symbol}).", trade.TradeId, trade.Symbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,22 +121,18 @@ public class ActiveTradeMonitorWorker : BackgroundService
|
||||
private async Task ProcessTradeAsync(TradeProposalDto trade, IN8nEvaluationService n8nService,
|
||||
IVixTrackerService vixService, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. Get Live Price
|
||||
var livePriceReq = new IsinRequest(trade.Isin);
|
||||
var livePriceDto = await _mqttClient.SendRpcRequestAsync<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",
|
||||
@@ -176,7 +169,6 @@ public class ActiveTradeMonitorWorker : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Run AI evaluation for soft/dynamic updates
|
||||
var taResult = await _mqttClient.SendRpcRequestAsync<TechnicalAnalysisDto, IsinRequest>(
|
||||
"ta_GetAnalysis", livePriceReq, TimeSpan.FromSeconds(5));
|
||||
|
||||
@@ -225,8 +217,7 @@ public class ActiveTradeMonitorWorker : BackgroundService
|
||||
var aiResponse = await n8nService.EvaluateAssetAsync(n8nReq, cancellationToken);
|
||||
if (aiResponse == null)
|
||||
{
|
||||
_logger.LogWarning("[{Channel}] AI evaluation returned null for {TradeId}. Skipping update.",
|
||||
"AnalyzerChannel", trade.TradeId);
|
||||
await _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] AI evaluation returned null for {TradeId}. Skipping update.", trade.TradeId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -235,7 +226,6 @@ public class ActiveTradeMonitorWorker : BackgroundService
|
||||
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);
|
||||
@@ -256,13 +246,11 @@ public class ActiveTradeMonitorWorker : BackgroundService
|
||||
}
|
||||
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;
|
||||
@@ -271,7 +259,6 @@ public class ActiveTradeMonitorWorker : BackgroundService
|
||||
}
|
||||
else
|
||||
{
|
||||
// Bei Short darf der StopLoss nur NACH UNTEN angepasst werden
|
||||
if (trade.StopLoss <= 0 || proposedSl < trade.StopLoss)
|
||||
{
|
||||
newStopLoss = proposedSl;
|
||||
@@ -310,18 +297,16 @@ public class ActiveTradeMonitorWorker : BackgroundService
|
||||
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);
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ActiveTradeMonitorWorker] Published trade update for {TradeId} to topic {Topic}. Recommendation: {Rec}, Reasoning: {Reason}",
|
||||
trade.TradeId, topic, recommendation, reasoning);
|
||||
}
|
||||
|
||||
private static int EstimateMaxHoldingDays(string timeframe)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(timeframe)) return 14; // Default
|
||||
if (string.IsNullOrWhiteSpace(timeframe)) return 14;
|
||||
|
||||
string tfLower = timeframe.ToLowerInvariant();
|
||||
int multiplier = 1;
|
||||
@@ -351,7 +336,7 @@ public class ActiveTradeMonitorWorker : BackgroundService
|
||||
int maxNum = numbers.Count > 0 ? numbers.Max() : 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;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticAnalyzer.Util;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Services;
|
||||
using FinlyticCore.Util;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public class N8nEvaluationService : IN8nEvaluationService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<N8nEvaluationService> _logger;
|
||||
private readonly IFinlyticLogger<N8nEvaluationService> _finlyticLogger;
|
||||
private readonly string _webhookUrl;
|
||||
|
||||
public N8nEvaluationService(HttpClient httpClient, IConfiguration configuration, ILogger<N8nEvaluationService> logger)
|
||||
public N8nEvaluationService(HttpClient httpClient, IConfiguration configuration, IFinlyticLogger<N8nEvaluationService> finlyticLogger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
_finlyticLogger = finlyticLogger;
|
||||
_webhookUrl = configuration["N8N:WebhookUrl"] ?? configuration["N8N__WebhookUrl"] ?? string.Empty;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -31,16 +38,15 @@ public class N8nEvaluationService : IN8nEvaluationService
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[N8nEvaluationService] Sending n8n AI Evaluation request {RequestId} for Asset {Symbol} (ISIN: {Isin}) to {Url}...",
|
||||
request.RequestId, request.TargetAsset.Symbol, request.TargetAsset.Isin, _webhookUrl);
|
||||
|
||||
// Typsichere AOT-Serialisierung verwenden
|
||||
using var content = JsonContent.Create(
|
||||
request,
|
||||
FinlyticJsonSerializerContext.Default.N8nAnalysisRequestDto);
|
||||
@@ -53,11 +59,10 @@ public class N8nEvaluationService : IN8nEvaluationService
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
// N8n schickt Ergebnisse manchmal als JSON-Array [{...}] zurück
|
||||
string jsonToDeserialize = contentStr.Trim();
|
||||
if (jsonToDeserialize.StartsWith('[') && jsonToDeserialize.EndsWith(']'))
|
||||
{
|
||||
@@ -74,28 +79,28 @@ public class N8nEvaluationService : IN8nEvaluationService
|
||||
|
||||
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);
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[N8nEvaluationService] Received n8n AI Response for Request {RequestId}: Decision={Decision}, Score={Score:F2}, Direction={Direction}, Timeframe={Timeframe}",
|
||||
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);
|
||||
await _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, "[N8nEvaluationService] n8n Webhook returned HTTP {StatusCode} for Request {RequestId}",
|
||||
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);
|
||||
await _finlyticLogger.LogErrorAsync(SettingKeys.AnalyzerChannel, ex, "[N8nEvaluationService] Timeout waiting for n8n AI Evaluation Webhook (Request {RequestId}). Consider increasing timeout.", request.RequestId);
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using FinlyticAnalyzer.Util;
|
||||
using FinlyticCore.Dtos.News;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using FinlyticCore.Services;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
|
||||
{
|
||||
private readonly ILogger<ThreeLayerFilterEngine> _logger;
|
||||
private readonly IFinlyticLogger<ThreeLayerFilterEngine> _finlyticLogger;
|
||||
private readonly ConcurrentDictionary<string, DateTime> _seenEvents = new();
|
||||
private readonly object _cleanupLock = new();
|
||||
private DateTime _lastCleanupTime = DateTime.UtcNow;
|
||||
|
||||
public ThreeLayerFilterEngine(ILogger<ThreeLayerFilterEngine> logger)
|
||||
public ThreeLayerFilterEngine(IFinlyticLogger<ThreeLayerFilterEngine> finlyticLogger)
|
||||
{
|
||||
_logger = logger;
|
||||
_finlyticLogger = finlyticLogger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -25,9 +26,6 @@ public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
|
||||
{
|
||||
var result = new FilterResult();
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Layer 1: Relevance, ISIN & Deduplication
|
||||
// -------------------------------------------------------------
|
||||
if (newsEvent == null || newsEvent.Id == Guid.Empty)
|
||||
{
|
||||
result.Passed = false;
|
||||
@@ -38,7 +36,6 @@ public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
|
||||
string eventId = newsEvent.Id.ToString();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Safely clean up dictionary every 30 minutes (thread-safe lock)
|
||||
if ((now - _lastCleanupTime).TotalMinutes > 30 || _seenEvents.Count > 10000)
|
||||
{
|
||||
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)
|
||||
{
|
||||
result.Passed = false;
|
||||
@@ -63,7 +59,6 @@ public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
|
||||
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];
|
||||
@@ -71,7 +66,6 @@ public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
|
||||
assetName = !string.IsNullOrWhiteSpace(firstAsset.Name) ? firstAsset.Name.Trim() : string.Empty;
|
||||
}
|
||||
|
||||
// Mandatory check: Must have a valid ISIN
|
||||
if (string.IsNullOrWhiteSpace(isin))
|
||||
{
|
||||
result.Passed = false;
|
||||
@@ -80,13 +74,9 @@ public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
|
||||
}
|
||||
|
||||
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
|
||||
result.Sector = "General";
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Layer 2: Impact & Dynamic VIX Threshold
|
||||
// -------------------------------------------------------------
|
||||
double impactScore = newsEvent.Confidence ?? 0.75;
|
||||
if (impactScore <= 0) impactScore = 0.75;
|
||||
|
||||
@@ -106,14 +96,11 @@ public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
|
||||
{
|
||||
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);
|
||||
_ = _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ThreeLayerFilterEngine] Event {EventId} (ISIN: {Isin}) rejected by Layer 2 filter. Impact: {Impact:F2}, Threshold: {Threshold:F2}, Regime: {Regime}",
|
||||
eventId, isin, impactScore, requiredThreshold, regime);
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Layer 3: Dynamic Parameter & Risk Engine
|
||||
// -------------------------------------------------------------
|
||||
result.RiskTolerance = regime switch
|
||||
{
|
||||
VixMarketRegime.Panic => "Conservative",
|
||||
@@ -125,8 +112,8 @@ public class ThreeLayerFilterEngine : IThreeLayerFilterEngine
|
||||
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);
|
||||
_ = _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ThreeLayerFilterEngine] Event {EventId} passed 3-Layer Filter for ISIN {Isin}. Impact: {Impact:F2}, Regime: {Regime}",
|
||||
eventId, result.Isin, impactScore, regime);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticAnalyzer.Util;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Services;
|
||||
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 readonly IFinlyticLogger<VixTrackerService> _finlyticLogger;
|
||||
|
||||
private decimal _currentVix = 18.5m; // Default: Normal Regime
|
||||
private decimal _currentVix = 18.5m;
|
||||
private VixMarketRegime _currentRegime = VixMarketRegime.Normal;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public VixTrackerService(YahooFinanceClient yahooClient, ILogger<VixTrackerService> logger)
|
||||
public VixTrackerService(YahooFinanceClient yahooClient, IFinlyticLogger<VixTrackerService> finlyticLogger)
|
||||
{
|
||||
_yahooClient = yahooClient;
|
||||
_logger = logger;
|
||||
_finlyticLogger = finlyticLogger;
|
||||
}
|
||||
|
||||
public decimal GetCurrentVix()
|
||||
@@ -52,13 +53,13 @@ public class VixTrackerService : IVixTrackerService
|
||||
|
||||
if (oldRegime != _currentRegime)
|
||||
{
|
||||
_logger.LogWarning("[{Channel}] [VIX REGIME SHIFT] Markt-Regime gewechselt: {OldRegime} -> {NewRegime} (VIX: {Vix:F2})",
|
||||
"AnalyzerChannel", oldRegime, _currentRegime, _currentVix);
|
||||
_ = _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, "[VixTrackerService] [VIX REGIME SHIFT] Markt-Regime gewechselt: {OldRegime} -> {NewRegime} (VIX: {Vix:F2})",
|
||||
oldRegime, _currentRegime, _currentVix);
|
||||
}
|
||||
else if (Math.Abs(oldVix - vixValue) >= 0.5m)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] VIX aktualisiert: {Vix:F2} (Regime: {Regime})",
|
||||
"AnalyzerChannel", _currentVix, _currentRegime);
|
||||
_ = _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[VixTrackerService] VIX aktualisiert: {Vix:F2} (Regime: {Regime})",
|
||||
_currentVix, _currentRegime);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,12 +78,10 @@ public class VixTrackerService : IVixTrackerService
|
||||
}
|
||||
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());
|
||||
await _finlyticLogger.LogWarningAsync(SettingKeys.AnalyzerChannel, ex, "[VixTrackerService] Fehler beim Abfragen von ^VIX über YahooFinanceClient. Nutze gecachten Wert {Vix}.", GetCurrentVix());
|
||||
}
|
||||
|
||||
return GetCurrentVix();
|
||||
|
||||
@@ -3,15 +3,16 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using FinlyticAnalyzer.Util;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Models.Trades;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using FinlyticCore.Services;
|
||||
|
||||
namespace FinlyticAnalyzer.Services;
|
||||
|
||||
public class WinRateCalculator : IWinRateCalculator
|
||||
{
|
||||
private readonly ILogger<WinRateCalculator> _logger;
|
||||
private readonly IFinlyticLogger<WinRateCalculator> _finlyticLogger;
|
||||
private readonly string _feedbackDir;
|
||||
|
||||
private readonly object _cacheLock = new();
|
||||
@@ -19,9 +20,9 @@ public class WinRateCalculator : IWinRateCalculator
|
||||
private DateTime _lastCacheTime = DateTime.MinValue;
|
||||
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");
|
||||
if (!Directory.Exists(_feedbackDir))
|
||||
{
|
||||
@@ -31,7 +32,6 @@ public class WinRateCalculator : IWinRateCalculator
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
public double CalculateWinRate(string sector, string symbol, VixMarketRegime regime)
|
||||
{
|
||||
@@ -53,27 +53,23 @@ public class WinRateCalculator : IWinRateCalculator
|
||||
{
|
||||
try
|
||||
{
|
||||
// 1. N8n AI Confidence Score (Weight: 40%)
|
||||
double n8nComponent = 62.0;
|
||||
if (n8nEvalScore.HasValue && n8nEvalScore.Value > 0)
|
||||
{
|
||||
n8nComponent = n8nEvalScore.Value <= 1.0 ? n8nEvalScore.Value * 100.0 : n8nEvalScore.Value;
|
||||
}
|
||||
|
||||
// 2. Technical Score (Weight: 30%)
|
||||
double taComponent = 60.0;
|
||||
if (technicalScore.HasValue && technicalScore.Value > 0)
|
||||
{
|
||||
taComponent = technicalScore.Value <= 1.0 ? technicalScore.Value * 100.0 : technicalScore.Value;
|
||||
}
|
||||
|
||||
// 3. Sentiment Score (Weight: 15%)
|
||||
double sentComponent = 58.0;
|
||||
if (sentimentScore.HasValue)
|
||||
{
|
||||
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);
|
||||
}
|
||||
else
|
||||
@@ -82,29 +78,25 @@ public class WinRateCalculator : IWinRateCalculator
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fundamental Score (Weight: 15%)
|
||||
double fundComponent = 60.0;
|
||||
if (fundamentalScore.HasValue && fundamentalScore.Value > 0)
|
||||
{
|
||||
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);
|
||||
|
||||
// 5. Market Regime & Volatility Adjustment
|
||||
double vixAdjustment = regime switch
|
||||
{
|
||||
VixMarketRegime.LowVol => +4.0, // Calm trending market
|
||||
VixMarketRegime.Normal => +1.5, // Normal conditions
|
||||
VixMarketRegime.HighVol => -3.5, // Increased whipsaws
|
||||
VixMarketRegime.Panic => -8.0, // High panic / uncertainty
|
||||
VixMarketRegime.LowVol => +4.0,
|
||||
VixMarketRegime.Normal => +1.5,
|
||||
VixMarketRegime.HighVol => -3.5,
|
||||
VixMarketRegime.Panic => -8.0,
|
||||
_ => 0.0
|
||||
};
|
||||
|
||||
composite += vixAdjustment;
|
||||
|
||||
// 6. Historical track record calibration (if available in feedback records)
|
||||
var records = GetCachedOrLoadRecords();
|
||||
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);
|
||||
|
||||
_logger.LogInformation("[{Channel}] 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);
|
||||
_ = _finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[WinRateCalculator] Dynamic Win-Rate for {Symbol} ({Sector}): {WinRate:F1}% [AI: {N8n:F1}%, TA: {TA:F1}%, Sent: {Sent:F1}%, Regime: {Regime}]",
|
||||
symbol, sector, finalWinRate, n8nComponent, taComponent, sentComponent, regime);
|
||||
|
||||
return finalWinRate;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -162,7 +153,7 @@ public class WinRateCalculator : IWinRateCalculator
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ using FinlyticAnalyzer.Database;
|
||||
using FinlyticAnalyzer.Entities;
|
||||
using FinlyticAnalyzer.Services;
|
||||
using FinlyticCore.Dtos;
|
||||
using FinlyticCore.Dtos.Settings;
|
||||
using FinlyticCore.Models;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
using FinlyticCore.Models.Trades;
|
||||
using FinlyticCore.Services;
|
||||
using FinlyticCore.Util;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
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}"
|
||||
};
|
||||
|
||||
_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);
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] Stopping Unified Analyzer MQTT Client.", "AnalyzerChannel");
|
||||
_logger.LogInformation("Stopping Unified Analyzer MQTT Client.");
|
||||
await DisconnectAsync();
|
||||
}
|
||||
|
||||
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
|
||||
await SubscribeAsync("services/news/#");
|
||||
@@ -85,16 +87,29 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
|
||||
await SubscribeAsync("services/config/updated/#");
|
||||
await SubscribeAsync("services/request/health_Ping/#");
|
||||
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/#");
|
||||
|
||||
// RPC Response Channels
|
||||
await SubscribeAsync("services/response/ta_GetAnalysis/#");
|
||||
await SubscribeAsync("services/response/fundamentals_Get/#");
|
||||
await SubscribeAsync("services/response/sentiment_GetIsin/#");
|
||||
await SubscribeAsync("services/response/sentiment_Analyze/#");
|
||||
await SubscribeAsync("services/response/trades_Get/#");
|
||||
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)
|
||||
@@ -114,10 +129,9 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
|
||||
string respTopic = $"services/response/health_Ping/{correlationId}";
|
||||
var healthResp = new ServiceHealthResponse("FinlyticAnalyzer", "Online", DateTime.UtcNow, "Connected");
|
||||
await PublishAsync(respTopic, healthResp);
|
||||
if (LogCategoryFilter.IsEnabled(LogCategory.MqttHealthPing))
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] [AnalyzerMqttClient] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", "AnalyzerChannel", correlationId);
|
||||
}
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
|
||||
await finlyticLogger.LogInfoAsync(SettingKeys.HealthPingChannel, "[AnalyzerMqttClient] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", correlationId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -126,21 +140,22 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
|
||||
{
|
||||
if (topic.EndsWith("FinlyticAnalyzer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] [AnalyzerMqttClient] Received config update event for FinlyticAnalyzer.", "AnalyzerChannel");
|
||||
try
|
||||
{
|
||||
var configUpdate = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.ServiceConfigUpdatePayload);
|
||||
if (configUpdate?.Settings != null && configUpdate.Settings.Count > 0)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var settingsDb = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
|
||||
await settingsDb.UpdateSettingsFromDictionaryAsync(configUpdate.Settings);
|
||||
_logger.LogInformation("[{Channel}] [AnalyzerMqttClient] Persisted {Count} updated settings to FinlyticAnalyzer database.", "AnalyzerChannel", configUpdate.Settings.Count);
|
||||
var settings = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||
var dict = configUpdate.Settings.ToDictionary(k => k.Key, v => (object?)v.Value);
|
||||
await settings.UpdateSettingsAsync(dict);
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -160,6 +175,16 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
|
||||
var correlationId = topic.Split('/').Last();
|
||||
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/"))
|
||||
{
|
||||
await HandleClosedTradeFeedbackAsync(payloadStr);
|
||||
@@ -167,12 +192,80 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
|
||||
}
|
||||
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)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
|
||||
|
||||
try
|
||||
{
|
||||
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");
|
||||
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)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
|
||||
|
||||
try
|
||||
{
|
||||
var manualReq = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.ManualAnalysisRpcRequest);
|
||||
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;
|
||||
}
|
||||
|
||||
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerManual))
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] [ManualAnalyzer] [TRIGGERED] Processing rich manual trigger for ISIN '{Isin}' (Symbol: {Symbol}). CorrelationId: {CorrelationId}", "AnalyzerChannel", manualReq.Isin, manualReq.Symbol, correlationId);
|
||||
}
|
||||
await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[ManualAnalyzer] [TRIGGERED] Processing rich manual trigger for ISIN '{Isin}' (Symbol: {Symbol}). CorrelationId: {CorrelationId}", manualReq.Isin, manualReq.Symbol, correlationId);
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<AnalyzerDbContext>();
|
||||
|
||||
var regime = _vixTracker.GetCurrentRegime();
|
||||
@@ -325,9 +417,8 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
|
||||
|
||||
var n8nResponse = await _n8nService.EvaluateAssetAsync(n8nRequest, cancellationToken);
|
||||
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
|
||||
var settings = await settingsService.GetSettingsAsync();
|
||||
double minSignalScore = settings.MinSignalScore;
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||
double minSignalScore = await settingsService.GetSettingAsync(SettingKeys.MinWinRateThreshold, cancellationToken);
|
||||
|
||||
double dynamicWinRate = _winRateCalculator.CalculateDynamicWinRate(
|
||||
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()}";
|
||||
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)
|
||||
{
|
||||
_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
|
||||
{
|
||||
var errorResponse = new ManualAnalysisResponseDto
|
||||
@@ -439,7 +530,7 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
|
||||
}
|
||||
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)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AnalyzerMqttClient>>();
|
||||
|
||||
var newsArticle = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.NewsArticleDto);
|
||||
if (newsArticle == null) return;
|
||||
|
||||
@@ -474,17 +568,11 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
|
||||
var filterResult = _filterEngine.EvaluateNews(newsArticle, regime);
|
||||
if (!filterResult.Passed)
|
||||
{
|
||||
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerAuto))
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] [AutoScreener] [SKIPPED] News message skipped for ISIN '{Isin}'. Reason: {Reason}", "AnalyzerChannel", filterResult.Isin, filterResult.RejectReason);
|
||||
}
|
||||
await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[AutoScreener] [SKIPPED] News message skipped for ISIN '{Isin}'. Reason: {Reason}", filterResult.Isin, filterResult.RejectReason);
|
||||
return;
|
||||
}
|
||||
|
||||
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerAuto))
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] [AutoScreener] [TRIGGERED] Screening market opportunity for ISIN '{Isin}'...", "AnalyzerChannel", filterResult.Isin);
|
||||
}
|
||||
await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[AutoScreener] [TRIGGERED] Screening market opportunity for ISIN '{Isin}'...", filterResult.Isin);
|
||||
|
||||
string analysisId = Guid.NewGuid().ToString("N");
|
||||
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);
|
||||
|
||||
// Parallel RPC calls (was sequential — up to 12s latency reduced to ~3s)
|
||||
var livePriceTask = SendRpcRequestAsync<FinlyticCore.Dtos.TechnicalAnalysis.LivePriceDto, IsinRequest>(
|
||||
"tr_GetLivePrice", isinReq, TimeSpan.FromSeconds(5));
|
||||
var taTask = SendRpcRequestAsync<FinlyticCore.Dtos.TechnicalAnalysis.TechnicalAnalysisDto, IsinRequest>(
|
||||
@@ -606,7 +693,6 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
|
||||
if (sentResp != null)
|
||||
{
|
||||
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);
|
||||
|
||||
sentInfo = new SentimentContextInfo
|
||||
@@ -620,7 +706,7 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
|
||||
}
|
||||
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
|
||||
@@ -670,13 +756,8 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
|
||||
|
||||
var n8nResponse = await _n8nService.EvaluateAssetAsync(n8nRequest, cancellationToken);
|
||||
|
||||
double minSignalScore = 75.0;
|
||||
using (var scope = _scopeFactory.CreateScope())
|
||||
{
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
|
||||
var settings = await settingsService.GetSettingsAsync();
|
||||
minSignalScore = settings.MinSignalScore;
|
||||
}
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||
double minSignalScore = await settingsService.GetSettingAsync(SettingKeys.MinWinRateThreshold, cancellationToken);
|
||||
|
||||
double confidenceScore = n8nResponse?.EvalScore > 0 ? n8nResponse.EvalScore : 0.75;
|
||||
bool isHighConviction = n8nResponse != null &&
|
||||
@@ -752,47 +833,44 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
|
||||
sentimentScore: sentResp?.CurrentSummary?.CompoundScore,
|
||||
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>();
|
||||
|
||||
bool hasRecentProposal = await dbContext.Analyses.AnyAsync(a =>
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
|
||||
if (isHighConviction && n8nResponse != null)
|
||||
{
|
||||
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()}";
|
||||
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)
|
||||
@@ -839,19 +917,13 @@ public class AnalyzerMqttClient : ManagedMqttClient, IHostedService
|
||||
await PublishAsync(recTopic, recommendation);
|
||||
await PublishAsync("finlytic/recommendations/auto", recommendation);
|
||||
|
||||
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerAuto))
|
||||
{
|
||||
_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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerAuto))
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] [AutoScreener] [DROPPED] Low-conviction signal for {Symbol} dropped (Confidence: {Score:F2}, Action: NO_ACTION)",
|
||||
"AnalyzerChannel", finalSymbol, recommendation.RecommendedAsset.ConfidenceScore);
|
||||
}
|
||||
await finlyticLogger.LogInfoAsync(SettingKeys.AnalyzerChannel, "[AutoScreener] [DROPPED] Low-conviction signal for {Symbol} dropped (Confidence: {Score:F2}, Action: NO_ACTION)",
|
||||
finalSymbol, recommendation.RecommendedAsset.ConfidenceScore);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user