diff --git a/FinlyticAnalyzer/Controllers/ManualAnalysisController.cs b/FinlyticAnalyzer/Controllers/ManualAnalysisController.cs new file mode 100644 index 0000000..e09e9ff --- /dev/null +++ b/FinlyticAnalyzer/Controllers/ManualAnalysisController.cs @@ -0,0 +1,190 @@ +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FinlyticAnalyzer.Database; +using FinlyticAnalyzer.Entities; +using FinlyticAnalyzer.Services; +using FinlyticCore.Models.Analyzer; +using FinlyticCore.Models.Trades; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace FinlyticAnalyzer.Controllers; + +public class ManualAnalysisRequest +{ + public string Symbol { get; set; } = string.Empty; + public string Isin { get; set; } = string.Empty; + public string Sector { get; set; } = "Technology"; + public string Headline { get; set; } = "Manual User Request"; + public decimal CurrentPrice { get; set; } = 100.0m; + public int RiskScore { get; set; } = 50; // 0 to 100 + public int MinTimeframeValue { get; set; } = 4; + public int MaxTimeframeValue { get; set; } = 6; + public string TimeframeUnit { get; set; } = "Tage"; + public string InstrumentType { get; set; } = "Stock"; + public string UserNotes { get; set; } = string.Empty; +} + +[ApiController] +[Route("api/v1/analyze")] +public class ManualAnalysisController : ControllerBase +{ + private readonly IVixTrackerService _vixTracker; + private readonly IN8nEvaluationService _n8nService; + private readonly IWinRateCalculator _winRateCalculator; + private readonly AnalyzerDbContext _dbContext; + private readonly ILogger _logger; + + public ManualAnalysisController( + IVixTrackerService vixTracker, + IN8nEvaluationService n8nService, + IWinRateCalculator winRateCalculator, + AnalyzerDbContext dbContext, + ILogger logger) + { + _vixTracker = vixTracker; + _n8nService = n8nService; + _winRateCalculator = winRateCalculator; + _dbContext = dbContext; + _logger = logger; + } + + /// + /// Runs a manual analysis based on the provided request. + /// + [HttpPost("manual")] + public async Task RunManualAnalysis([FromBody] ManualAnalysisRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Symbol) && string.IsNullOrWhiteSpace(request.Isin)) + { + return BadRequest(new { error = "Symbol or ISIN is required." }); + } + + var regime = _vixTracker.GetCurrentRegime(); + var currentVix = _vixTracker.GetCurrentVix(); + string analysisId = Guid.NewGuid().ToString("N"); + double winRate = _winRateCalculator.CalculateWinRate(request.Sector, request.Symbol, regime); + + string riskLabel = request.RiskScore > 70 ? $"Aggressiv ({request.RiskScore}/100)" : (request.RiskScore > 30 ? $"Balanced ({request.RiskScore}/100)" : $"Konservativ ({request.RiskScore}/100)"); + string timeframeFormatted = $"{request.MinTimeframeValue}-{request.MaxTimeframeValue} {request.TimeframeUnit}"; + + var n8nRequest = new N8nAnalysisRequestDto + { + RequestId = analysisId, + Timestamp = DateTime.UtcNow, + TriggerType = "Manual", + TargetAsset = new TargetAssetInfo + { + Symbol = request.Symbol.ToUpperInvariant(), + Isin = request.Isin.ToUpperInvariant(), + Sector = request.Sector + }, + MarketContext = new MarketContextInfo + { + Vix = currentVix, + MarketRegime = regime.ToString() + }, + FilterContext = new FilterContextInfo + { + ImpactScore = 1.0, + RawNewsHeadline = string.IsNullOrWhiteSpace(request.Headline) ? "Manual User Trigger" : request.Headline + }, + UserPreferences = new UserPreferencesInfo + { + RiskScore = request.RiskScore, + RiskTolerance = riskLabel, + MinTimeframeValue = request.MinTimeframeValue, + MaxTimeframeValue = request.MaxTimeframeValue, + TimeframeUnit = request.TimeframeUnit, + TimeframeFormatted = timeframeFormatted, + InstrumentType = request.InstrumentType, + UserNotes = request.UserNotes + }, + TradeFeedback = new TradeFeedbackInfo + { + TotalAssetTrades = 12, + AssetWinRate = winRate, + AvgReturnPercent = 3.4, + LastTradeResult = "WIN" + } + }; + + var n8nResponse = await _n8nService.EvaluateAssetAsync(n8nRequest, cancellationToken); + bool shouldProceed = n8nResponse != null && string.Equals(n8nResponse.AiDecision, "Proceed", StringComparison.OrdinalIgnoreCase); + + TradeProposalDto? proposal = null; + if (shouldProceed && n8nResponse != null) + { + proposal = new TradeProposalDto + { + AnalysisId = analysisId, + EventId = analysisId, + Sector = request.Sector, + Symbol = request.Symbol.ToUpperInvariant(), + Isin = request.Isin.ToUpperInvariant(), + CompanyName = request.Symbol, + EntryPrice = request.CurrentPrice, + SignalType = string.Equals(n8nResponse.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) ? "SELL" : "BUY", + RiskTolerance = n8nResponse.SuggestedRisk, + Timeframe = timeframeFormatted, + InstrumentType = request.InstrumentType, + WinRate = winRate, + VixRegime = regime, + VixValue = currentVix, + TtlMinutes = 60, + Reasoning = $"Manual n8n Evaluation ({n8nResponse.AiDecision}): {n8nResponse.AiReasoning}", + CreatedAt = DateTime.UtcNow + }; + } + + var analysisEntity = new AnalysisEntity + { + AnalysisId = analysisId, + EventId = analysisId, + Sector = request.Sector, + Symbol = request.Symbol.ToUpperInvariant(), + Isin = request.Isin.ToUpperInvariant(), + VixRegime = regime, + VixValue = currentVix, + ImpactScore = 1.0, + WinRate = winRate, + RawDataJson = JsonSerializer.Serialize(request), + AiOutputJson = proposal != null ? JsonSerializer.Serialize(proposal) : "{}", + N8nResponseJson = n8nResponse != null ? JsonSerializer.Serialize(n8nResponse) : "{}", + N8nEvalScore = n8nResponse?.EvalScore ?? 0, + N8nDecision = n8nResponse?.AiDecision ?? "Rejected", + IsTradeProposed = shouldProceed, + CreatedAt = DateTime.UtcNow + }; + + _dbContext.Analyses.Add(analysisEntity); + await _dbContext.SaveChangesAsync(cancellationToken); + + if (!shouldProceed) + { + return Ok(new + { + analysisId, + isTradeProposed = false, + status = "Rejected", + recommendation = "NOT_RECOMMENDED", + reasoning = n8nResponse?.AiReasoning ?? "Die KI stuft diesen Trade als zu riskant ein und empfiehlt keine Positionierung.", + n8nResponse, + proposal = (object?)null + }); + } + + return Ok(new + { + analysisId, + isTradeProposed = true, + status = "Success", + recommendation = "RECOMMENDED", + n8nResponse, + proposal + }); + } +} diff --git a/FinlyticAnalyzer/Database/AnalyzerDbContext.cs b/FinlyticAnalyzer/Database/AnalyzerDbContext.cs new file mode 100644 index 0000000..befdba3 --- /dev/null +++ b/FinlyticAnalyzer/Database/AnalyzerDbContext.cs @@ -0,0 +1,33 @@ +using FinlyticAnalyzer.Entities; +using Microsoft.EntityFrameworkCore; + +namespace FinlyticAnalyzer.Database; + +public class AnalyzerDbContext : DbContext +{ + public AnalyzerDbContext(DbContextOptions options) : base(options) { } + + public DbSet Analyses => Set(); + public DbSet Settings => Set(); + public DbSet TradeProposals => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity(entity => + { + entity.HasIndex(e => e.AnalysisId).IsUnique(); + entity.HasIndex(e => e.EventId); + entity.HasIndex(e => e.Isin); + entity.HasIndex(e => e.Sector); + entity.HasIndex(e => e.CreatedAt); + }); + + modelBuilder.Entity(entity => + { + entity.HasIndex(e => e.Isin); + entity.HasIndex(e => e.ExpiresAt); + }); + } +} diff --git a/FinlyticAnalyzer/Dockerfile b/FinlyticAnalyzer/Dockerfile new file mode 100644 index 0000000..c221872 --- /dev/null +++ b/FinlyticAnalyzer/Dockerfile @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"] +COPY ["FinlyticAnalyzer/FinlyticAnalyzer.csproj", "FinlyticAnalyzer/"] +RUN dotnet restore "FinlyticAnalyzer/FinlyticAnalyzer.csproj" +COPY . . +WORKDIR "/src/FinlyticAnalyzer" +RUN dotnet build "FinlyticAnalyzer.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "FinlyticAnalyzer.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "FinlyticAnalyzer.dll"] diff --git a/FinlyticAnalyzer/Entities/AnalysisEntity.cs b/FinlyticAnalyzer/Entities/AnalysisEntity.cs new file mode 100644 index 0000000..17fa617 --- /dev/null +++ b/FinlyticAnalyzer/Entities/AnalysisEntity.cs @@ -0,0 +1,60 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using FinlyticCore.Models.Analyzer; + +namespace FinlyticAnalyzer.Entities; + +/// +/// Persisted raw news, market context, AI prompt payload & response in PostgreSQL. +/// +[Table("analyses")] +public class AnalysisEntity +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + [MaxLength(100)] + public string AnalysisId { get; set; } = string.Empty; + + [Required] + [MaxLength(100)] + public string EventId { get; set; } = string.Empty; + + [Required] + [MaxLength(50)] + public string Sector { get; set; } = string.Empty; + + [Required] + [MaxLength(30)] + public string Symbol { get; set; } = string.Empty; + + [Required] + [MaxLength(30)] + public string Isin { get; set; } = string.Empty; + + public VixMarketRegime VixRegime { get; set; } + public decimal VixValue { get; set; } + + public double ImpactScore { get; set; } + public double WinRate { get; set; } + + [Column(TypeName = "jsonb")] + public string RawDataJson { get; set; } = "{}"; + + [Column(TypeName = "jsonb")] + public string AiOutputJson { get; set; } = "{}"; + + [Column(TypeName = "jsonb")] + public string N8nResponseJson { get; set; } = "{}"; + + public double N8nEvalScore { get; set; } + + [MaxLength(30)] + public string N8nDecision { get; set; } = string.Empty; + + public bool IsTradeProposed { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/FinlyticAnalyzer/Entities/AnalyzerSettingsEntity.cs b/FinlyticAnalyzer/Entities/AnalyzerSettingsEntity.cs new file mode 100644 index 0000000..32196dd --- /dev/null +++ b/FinlyticAnalyzer/Entities/AnalyzerSettingsEntity.cs @@ -0,0 +1,21 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace FinlyticAnalyzer.Entities; + +public class AnalyzerSettingsEntity +{ + [Key] + public Guid Id { get; set; } + + public string ScanCronSchedule { get; set; } = "0 */1 * * *"; + public double MinSignalScore { get; set; } = 75.0; + + public bool EnableLogMqttHealthPing { get; set; } = false; + public bool EnableLogMqttGeneral { get; set; } = true; + public bool EnableLogAnalyzerAuto { get; set; } = true; + public bool EnableLogAnalyzerManual { get; set; } = true; + public bool EnableLogDatabaseOps { get; set; } = true; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/FinlyticAnalyzer/Entities/TradeProposalEntity.cs b/FinlyticAnalyzer/Entities/TradeProposalEntity.cs new file mode 100644 index 0000000..657ec4d --- /dev/null +++ b/FinlyticAnalyzer/Entities/TradeProposalEntity.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using FinlyticCore.Models.Analyzer; +using FinlyticCore.Models.Assets; + +namespace FinlyticAnalyzer.Entities; + +[Table("trade_proposals")] +public class TradeProposalEntity +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + [MaxLength(100)] + public string AnalysisId { get; set; } = string.Empty; + + [Required] + [MaxLength(100)] + public string EventId { get; set; } = string.Empty; + + [Required] + [MaxLength(30)] + public string Isin { get; set; } = string.Empty; + + [MaxLength(30)] + public string Symbol { get; set; } = string.Empty; + + [MaxLength(150)] + public string Name { get; set; } = string.Empty; + + [MaxLength(50)] + public string Sector { get; set; } = "General"; + + public AssetType Type { get; set; } = AssetType.Stock; + + /// + /// KI-Entscheidung ("BUY", "SELL", "HOLD", "REJECTED") + /// + [MaxLength(20)] + public string ProposedAction { get; set; } = "BUY"; + + public double ConfidenceScore { get; set; } + + // --- KI Execution Plan (Vorgeschlagene Preismarken) --- + [Column(TypeName = "decimal(18,4)")] + public decimal EntryPrice { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal StopLoss { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal TakeProfit { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? EntryZoneMin { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? EntryZoneMax { get; set; } + + public string? TakeProfitTargets { get; set; } // Comma-separated or JSON + + [Column(TypeName = "decimal(18,4)")] + public decimal? RiskRewardRatio { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal? MaxLeverage { get; set; } + + // --- Kontext aus Request & KI --- + public string ReasonSummary { get; set; } = string.Empty; + public string TechnicalRationale { get; set; } = string.Empty; + public string FundamentalRationale { get; set; } = string.Empty; + public string RiskWarning { get; set; } = string.Empty; + + [MaxLength(30)] + public string RiskTolerance { get; set; } = "Balanced"; + + [MaxLength(20)] + public string Timeframe { get; set; } = "1-7 Tage"; + + [MaxLength(30)] + public string InstrumentType { get; set; } = "KnockOut"; + + public VixMarketRegime VixRegime { get; set; } + + [Column(TypeName = "decimal(18,4)")] + public decimal VixValue { get; set; } + + public double WinRate { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime ExpiresAt { get; set; } = DateTime.UtcNow.AddHours(3); +} \ No newline at end of file diff --git a/FinlyticAnalyzer/FinlyticAnalyzer.csproj b/FinlyticAnalyzer/FinlyticAnalyzer.csproj new file mode 100644 index 0000000..fc1d158 --- /dev/null +++ b/FinlyticAnalyzer/FinlyticAnalyzer.csproj @@ -0,0 +1,26 @@ + + + + Exe + net10.0 + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/FinlyticAnalyzer/Migrations/20260801073402_Init.Designer.cs b/FinlyticAnalyzer/Migrations/20260801073402_Init.Designer.cs new file mode 100644 index 0000000..caea4df --- /dev/null +++ b/FinlyticAnalyzer/Migrations/20260801073402_Init.Designer.cs @@ -0,0 +1,136 @@ +// +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("20260801073402_Init")] + partial class Init + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiOutputJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("AnalysisId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EventId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ImpactScore") + .HasColumnType("double precision"); + + b.Property("IsTradeProposed") + .HasColumnType("boolean"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("N8nDecision") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("N8nEvalScore") + .HasColumnType("double precision"); + + b.Property("N8nResponseJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RawDataJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Sector") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("VixRegime") + .HasColumnType("integer"); + + b.Property("VixValue") + .HasColumnType("numeric"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MinSignalScore") + .HasColumnType("double precision"); + + b.Property("ScanCronSchedule") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticAnalyzer/Migrations/20260801073402_Init.cs b/FinlyticAnalyzer/Migrations/20260801073402_Init.cs new file mode 100644 index 0000000..ebdcc08 --- /dev/null +++ b/FinlyticAnalyzer/Migrations/20260801073402_Init.cs @@ -0,0 +1,92 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticAnalyzer.Migrations +{ + /// + public partial class Init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "analyses", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + AnalysisId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + EventId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Sector = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + Symbol = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + Isin = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + VixRegime = table.Column(type: "integer", nullable: false), + VixValue = table.Column(type: "numeric", nullable: false), + ImpactScore = table.Column(type: "double precision", nullable: false), + WinRate = table.Column(type: "double precision", nullable: false), + RawDataJson = table.Column(type: "jsonb", nullable: false), + AiOutputJson = table.Column(type: "jsonb", nullable: false), + N8nResponseJson = table.Column(type: "jsonb", nullable: false), + N8nEvalScore = table.Column(type: "double precision", nullable: false), + N8nDecision = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + IsTradeProposed = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_analyses", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Settings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ScanCronSchedule = table.Column(type: "text", nullable: false), + MinSignalScore = table.Column(type: "double precision", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Settings", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_analyses_AnalysisId", + table: "analyses", + column: "AnalysisId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_analyses_CreatedAt", + table: "analyses", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_analyses_EventId", + table: "analyses", + column: "EventId"); + + migrationBuilder.CreateIndex( + name: "IX_analyses_Isin", + table: "analyses", + column: "Isin"); + + migrationBuilder.CreateIndex( + name: "IX_analyses_Sector", + table: "analyses", + column: "Sector"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "analyses"); + + migrationBuilder.DropTable( + name: "Settings"); + } + } +} diff --git a/FinlyticAnalyzer/Migrations/20260803185020_AddLogFilterSettings.Designer.cs b/FinlyticAnalyzer/Migrations/20260803185020_AddLogFilterSettings.Designer.cs new file mode 100644 index 0000000..da66b74 --- /dev/null +++ b/FinlyticAnalyzer/Migrations/20260803185020_AddLogFilterSettings.Designer.cs @@ -0,0 +1,151 @@ +// +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("20260803185020_AddLogFilterSettings")] + partial class AddLogFilterSettings + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiOutputJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("AnalysisId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EventId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ImpactScore") + .HasColumnType("double precision"); + + b.Property("IsTradeProposed") + .HasColumnType("boolean"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("N8nDecision") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("N8nEvalScore") + .HasColumnType("double precision"); + + b.Property("N8nResponseJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RawDataJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Sector") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("VixRegime") + .HasColumnType("integer"); + + b.Property("VixValue") + .HasColumnType("numeric"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EnableLogAnalyzerAuto") + .HasColumnType("boolean"); + + b.Property("EnableLogAnalyzerManual") + .HasColumnType("boolean"); + + b.Property("EnableLogDatabaseOps") + .HasColumnType("boolean"); + + b.Property("EnableLogMqttGeneral") + .HasColumnType("boolean"); + + b.Property("EnableLogMqttHealthPing") + .HasColumnType("boolean"); + + b.Property("MinSignalScore") + .HasColumnType("double precision"); + + b.Property("ScanCronSchedule") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticAnalyzer/Migrations/20260803185020_AddLogFilterSettings.cs b/FinlyticAnalyzer/Migrations/20260803185020_AddLogFilterSettings.cs new file mode 100644 index 0000000..2dfe8d0 --- /dev/null +++ b/FinlyticAnalyzer/Migrations/20260803185020_AddLogFilterSettings.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticAnalyzer.Migrations +{ + /// + public partial class AddLogFilterSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EnableLogAnalyzerAuto", + table: "Settings", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EnableLogAnalyzerManual", + table: "Settings", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EnableLogDatabaseOps", + table: "Settings", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EnableLogMqttGeneral", + table: "Settings", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "EnableLogMqttHealthPing", + table: "Settings", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EnableLogAnalyzerAuto", + table: "Settings"); + + migrationBuilder.DropColumn( + name: "EnableLogAnalyzerManual", + table: "Settings"); + + migrationBuilder.DropColumn( + name: "EnableLogDatabaseOps", + table: "Settings"); + + migrationBuilder.DropColumn( + name: "EnableLogMqttGeneral", + table: "Settings"); + + migrationBuilder.DropColumn( + name: "EnableLogMqttHealthPing", + table: "Settings"); + } + } +} diff --git a/FinlyticAnalyzer/Migrations/20260804184350_CheckPendingMigrations.Designer.cs b/FinlyticAnalyzer/Migrations/20260804184350_CheckPendingMigrations.Designer.cs new file mode 100644 index 0000000..4f45749 --- /dev/null +++ b/FinlyticAnalyzer/Migrations/20260804184350_CheckPendingMigrations.Designer.cs @@ -0,0 +1,151 @@ +// +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("20260804184350_CheckPendingMigrations")] + partial class CheckPendingMigrations + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiOutputJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("AnalysisId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EventId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ImpactScore") + .HasColumnType("double precision"); + + b.Property("IsTradeProposed") + .HasColumnType("boolean"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("N8nDecision") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("N8nEvalScore") + .HasColumnType("double precision"); + + b.Property("N8nResponseJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RawDataJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Sector") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("VixRegime") + .HasColumnType("integer"); + + b.Property("VixValue") + .HasColumnType("numeric"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EnableLogAnalyzerAuto") + .HasColumnType("boolean"); + + b.Property("EnableLogAnalyzerManual") + .HasColumnType("boolean"); + + b.Property("EnableLogDatabaseOps") + .HasColumnType("boolean"); + + b.Property("EnableLogMqttGeneral") + .HasColumnType("boolean"); + + b.Property("EnableLogMqttHealthPing") + .HasColumnType("boolean"); + + b.Property("MinSignalScore") + .HasColumnType("double precision"); + + b.Property("ScanCronSchedule") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticAnalyzer/Migrations/20260804184350_CheckPendingMigrations.cs b/FinlyticAnalyzer/Migrations/20260804184350_CheckPendingMigrations.cs new file mode 100644 index 0000000..780e3ea --- /dev/null +++ b/FinlyticAnalyzer/Migrations/20260804184350_CheckPendingMigrations.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticAnalyzer.Migrations +{ + /// + public partial class CheckPendingMigrations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/FinlyticAnalyzer/Migrations/20260805184638_AddTradeProposals.Designer.cs b/FinlyticAnalyzer/Migrations/20260805184638_AddTradeProposals.Designer.cs new file mode 100644 index 0000000..d9cddd8 --- /dev/null +++ b/FinlyticAnalyzer/Migrations/20260805184638_AddTradeProposals.Designer.cs @@ -0,0 +1,194 @@ +// +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("20260805184638_AddTradeProposals")] + partial class AddTradeProposals + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiOutputJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("AnalysisId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EventId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ImpactScore") + .HasColumnType("double precision"); + + b.Property("IsTradeProposed") + .HasColumnType("boolean"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("N8nDecision") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("N8nEvalScore") + .HasColumnType("double precision"); + + b.Property("N8nResponseJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RawDataJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Sector") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("VixRegime") + .HasColumnType("integer"); + + b.Property("VixValue") + .HasColumnType("numeric"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EnableLogAnalyzerAuto") + .HasColumnType("boolean"); + + b.Property("EnableLogAnalyzerManual") + .HasColumnType("boolean"); + + b.Property("EnableLogDatabaseOps") + .HasColumnType("boolean"); + + b.Property("EnableLogMqttGeneral") + .HasColumnType("boolean"); + + b.Property("EnableLogMqttHealthPing") + .HasColumnType("boolean"); + + b.Property("MinSignalScore") + .HasColumnType("double precision"); + + b.Property("ScanCronSchedule") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("FinlyticAnalyzer.Entities.TradeProposalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfidenceScore") + .HasColumnType("double precision"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Isin") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedAction") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReasonSummary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("Isin"); + + b.ToTable("TradeProposals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticAnalyzer/Migrations/20260805184638_AddTradeProposals.cs b/FinlyticAnalyzer/Migrations/20260805184638_AddTradeProposals.cs new file mode 100644 index 0000000..9b7758a --- /dev/null +++ b/FinlyticAnalyzer/Migrations/20260805184638_AddTradeProposals.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticAnalyzer.Migrations +{ + /// + public partial class AddTradeProposals : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TradeProposals", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Isin = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Type = table.Column(type: "integer", nullable: false), + ProposedAction = table.Column(type: "text", nullable: false), + ConfidenceScore = table.Column(type: "double precision", nullable: false), + ReasonSummary = table.Column(type: "text", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TradeProposals", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_TradeProposals_ExpiresAt", + table: "TradeProposals", + column: "ExpiresAt"); + + migrationBuilder.CreateIndex( + name: "IX_TradeProposals_Isin", + table: "TradeProposals", + column: "Isin"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TradeProposals"); + } + } +} diff --git a/FinlyticAnalyzer/Migrations/AnalyzerDbContextModelSnapshot.cs b/FinlyticAnalyzer/Migrations/AnalyzerDbContextModelSnapshot.cs new file mode 100644 index 0000000..009ae98 --- /dev/null +++ b/FinlyticAnalyzer/Migrations/AnalyzerDbContextModelSnapshot.cs @@ -0,0 +1,191 @@ +// +using System; +using FinlyticAnalyzer.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinlyticAnalyzer.Migrations +{ + [DbContext(typeof(AnalyzerDbContext))] + partial class AnalyzerDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiOutputJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("AnalysisId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EventId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ImpactScore") + .HasColumnType("double precision"); + + b.Property("IsTradeProposed") + .HasColumnType("boolean"); + + b.Property("Isin") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("N8nDecision") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("N8nEvalScore") + .HasColumnType("double precision"); + + b.Property("N8nResponseJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RawDataJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Sector") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("VixRegime") + .HasColumnType("integer"); + + b.Property("VixValue") + .HasColumnType("numeric"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EnableLogAnalyzerAuto") + .HasColumnType("boolean"); + + b.Property("EnableLogAnalyzerManual") + .HasColumnType("boolean"); + + b.Property("EnableLogDatabaseOps") + .HasColumnType("boolean"); + + b.Property("EnableLogMqttGeneral") + .HasColumnType("boolean"); + + b.Property("EnableLogMqttHealthPing") + .HasColumnType("boolean"); + + b.Property("MinSignalScore") + .HasColumnType("double precision"); + + b.Property("ScanCronSchedule") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("FinlyticAnalyzer.Entities.TradeProposalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfidenceScore") + .HasColumnType("double precision"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Isin") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProposedAction") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReasonSummary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("Isin"); + + b.ToTable("TradeProposals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticAnalyzer/Program.cs b/FinlyticAnalyzer/Program.cs new file mode 100644 index 0000000..c6b5e36 --- /dev/null +++ b/FinlyticAnalyzer/Program.cs @@ -0,0 +1,63 @@ +using System; +using FinlyticAnalyzer.Database; +using FinlyticAnalyzer.Services; +using FinlyticAnalyzer.Util; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// Register DB Context +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); + +// Register HTTP Clients for external scrapers/webhooks +builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(); + +// Register Domain Services +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +// Unified MQTT Client (Handles both Events and RPC) +builder.Services.AddSingleton(); +builder.Services.AddHostedService(provider => provider.GetRequiredService()); + +// Register Active Trade Monitor +builder.Services.AddHostedService(); + +var host = builder.Build(); + +// Run DB Migrations +using (var scope = host.Services.CreateScope()) +{ + try + { + var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.MigrateAsync(); + Console.WriteLine("Database migrations successfully executed for FinlyticAnalyzer."); + + var settingsService = scope.ServiceProvider.GetRequiredService(); + await settingsService.GetSettingsAsync(); + } + catch (Exception ex) + { + var logger = scope.ServiceProvider.GetRequiredService>(); + logger.LogError(ex, "An error occurred during database migration for FinlyticAnalyzer on startup."); + } +} + +// Initial VIX Poll +using (var scope = host.Services.CreateScope()) +{ + var vixService = scope.ServiceProvider.GetRequiredService(); + await vixService.PollVixAsync(); +} + +await host.RunAsync(); diff --git a/FinlyticAnalyzer/Project.md b/FinlyticAnalyzer/Project.md new file mode 100644 index 0000000..39df143 --- /dev/null +++ b/FinlyticAnalyzer/Project.md @@ -0,0 +1,37 @@ +# Finlytic Analyzer Service + +Finlytic Analyzer is the core quantitative decision engine of the Finlytic ecosystem. It evaluates multi-layered market filters, tracks VIX volatility regimes, evaluates AI win rates, and generates actionable trade proposals. + +--- + +## Core Features & Architecture + +1. **3-Layer Filter Engine (`IThreeLayerFilterEngine`)**: + - **Layer 1 (Macro VIX Regime)**: Evaluates overall volatility conditions via `IVixTrackerService`. + - **Layer 2 (Asset Technical Analysis & Indicators)**: Evaluates RSI, MACD, Moving Averages, and Supertrend alignment. + - **Layer 3 (AI Sentiment & Event Context)**: Evaluates FinBERT news sentiment scores and corporate earnings proximity. + +2. **VIX Volatility Tracker (`IVixTrackerService`)**: + - Polls external VIX volatility sources and categorizes market regimes (`Low`, `Normal`, `Elevated`, `High`). + +3. **Win-Rate Calculator (`IWinRateCalculator`)**: + - Calculates historical probability of success based on trade feedback records. + +4. **MQTT Signal Publisher (`AnalyzerMqttClient`)**: + - Publishes generated trade proposals to `finlytic/trades/proposed/{symbol}`. + +--- + +## Feature Status + +### Implemented Features +- [x] 3-Layer Quantitative Filter Engine (`ThreeLayerFilterEngine`). +- [x] VIX Volatility Regime Tracker (`VixTrackerService`). +- [x] Win-Rate Probability Calculator (`WinRateCalculator`). +- [x] n8n AI Evaluation Integration (`N8nEvaluationService`). +- [x] Pure Worker Service Architecture (`Host.CreateApplicationBuilder`, Kestrel webserver removed). +- [x] Zero-Allocation MQTT Signal Publishing (`AnalyzerMqttClient`). + +### Planned Features +- [ ] Multi-year historical Backtesting Engine with Monte Carlo simulation. +- [ ] Portfolio Risk Allocation & Kelly Criterion Position Sizing Engine. diff --git a/FinlyticAnalyzer/Services/ActiveTradeMonitorWorker.cs b/FinlyticAnalyzer/Services/ActiveTradeMonitorWorker.cs new file mode 100644 index 0000000..99b825c --- /dev/null +++ b/FinlyticAnalyzer/Services/ActiveTradeMonitorWorker.cs @@ -0,0 +1,357 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FinlyticAnalyzer.Util; +using FinlyticCore.Dtos; +using FinlyticCore.Dtos.TechnicalAnalysis; +using FinlyticCore.Models.Analyzer; +using FinlyticCore.Models.Trades; +using FinlyticCore.Util; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FinlyticAnalyzer.Services; + +public class ActiveTradeMonitorWorker : BackgroundService +{ + private readonly ILogger _logger; + private readonly IServiceScopeFactory _scopeFactory; + private readonly AnalyzerMqttClient _mqttClient; + + public ActiveTradeMonitorWorker(ILogger logger, IServiceScopeFactory scopeFactory, + AnalyzerMqttClient mqttClient) + { + _logger = logger; + _scopeFactory = scopeFactory; + _mqttClient = mqttClient; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("[{Channel}] ActiveTradeMonitorWorker started.", "AnalyzerChannel"); + + try + { + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + } + catch (OperationCanceledException) + { + return; + } + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await MonitorActiveTradesAsync(stoppingToken); + } + catch (Exception ex) when (!stoppingToken.IsCancellationRequested) + { + _logger.LogError(ex, "[{Channel}] Error in ActiveTradeMonitorWorker loop.", "AnalyzerChannel"); + } + + try + { + await Task.Delay(TimeSpan.FromMinutes(60), stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + + _logger.LogInformation("[{Channel}] ActiveTradeMonitorWorker stopped.", "AnalyzerChannel"); + } + + private async Task MonitorActiveTradesAsync(CancellationToken cancellationToken) + { + if (!_mqttClient.IsConnected) + { + _logger.LogWarning("[{Channel}] Skipping trade monitoring. RPC client not connected.", "AnalyzerChannel"); + return; + } + + // Fetch active trades + var activeTrades = await _mqttClient.SendRpcRequestAsync, GetTradesRequest>( + "trades_Get", + new GetTradesRequest(null, "Active"), + TimeSpan.FromSeconds(10)); + + // Fetch proposed global trades + var proposedTrades = await _mqttClient.SendRpcRequestAsync, GetTradesRequest>( + "trades_Get", + new GetTradesRequest(null, "Proposed"), + TimeSpan.FromSeconds(10)); + + var trades = new List(); + if (activeTrades != null) trades.AddRange(activeTrades); + if (proposedTrades != null) trades.AddRange(proposedTrades.Where(t => t.IsGlobalProposal)); + + if (trades.Count == 0) + { + _logger.LogInformation("[{Channel}] No active or proposed global trades found to monitor.", + "AnalyzerChannel"); + return; + } + + _logger.LogInformation("[{Channel}] Found {Count} trades to monitor. Starting evaluation...", "AnalyzerChannel", + trades.Count); + + using var scope = _scopeFactory.CreateScope(); + var n8nService = scope.ServiceProvider.GetRequiredService(); + + foreach (var trade in trades) + { + if (cancellationToken.IsCancellationRequested) break; + + try + { + await ProcessTradeAsync(trade, n8nService, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] Failed to monitor trade {TradeId} ({Symbol}).", "AnalyzerChannel", + trade.TradeId, trade.Symbol); + } + } + } + + private async Task ProcessTradeAsync(TradeProposalDto trade, IN8nEvaluationService n8nService, + CancellationToken cancellationToken) + { + // 1. Get Live Price + var livePriceReq = new IsinRequest(trade.Isin); + var livePriceDto = await _mqttClient.SendRpcRequestAsync( + "tr_GetLivePrice", livePriceReq, TimeSpan.FromSeconds(3)); + + decimal currentPrice = livePriceDto?.CurrentPrice > 0 ? livePriceDto.CurrentPrice : trade.EntryPrice; + + // 2. Evaluate Hard Stops (StopLoss / TakeProfit / TimeStop) + bool isLong = string.Equals(trade.SignalType, "BUY", StringComparison.OrdinalIgnoreCase) || + string.Equals(trade.SignalType, "LONG", StringComparison.OrdinalIgnoreCase); + + // Time-Stop Evaluierung + int maxHoldingDays = EstimateMaxHoldingDays(trade.Timeframe); + double daysOpen = (DateTime.UtcNow - trade.CreatedAt).TotalDays; + + // 50% Grace Period. Bei z.B. 10 Tagen max. Haltedauer wird nach 15 Tagen ohne Zielerreichung glattgestellt. + if (daysOpen > (maxHoldingDays * 1.5)) + { + await SendUpdateAsync(trade, currentPrice, "Close", + $"Time-Stop getriggert: Setup ist invalidiert. Der Trade bewegt sich zu lange seitwärts (Offen seit {(int)daysOpen} Tagen, anvisiert waren max. {maxHoldingDays} Tage)."); + return; + } + + if (isLong) + { + if (trade.StopLoss > 0 && currentPrice <= trade.StopLoss) + { + await SendUpdateAsync(trade, currentPrice, "Close", "Hard Stop-Loss getriggert."); + return; + } + + if (trade.TakeProfit > 0 && currentPrice >= trade.TakeProfit) + { + await SendUpdateAsync(trade, currentPrice, "Close", "Hard Take-Profit erreicht."); + return; + } + } + else + { + if (trade.StopLoss > 0 && currentPrice >= trade.StopLoss) + { + await SendUpdateAsync(trade, currentPrice, "Close", "Hard Stop-Loss getriggert."); + return; + } + + if (trade.TakeProfit > 0 && currentPrice <= trade.TakeProfit) + { + await SendUpdateAsync(trade, currentPrice, "Close", "Hard Take-Profit erreicht."); + return; + } + } + + // 3. Run AI evaluation for soft/dynamic updates + var taResult = await _mqttClient.SendRpcRequestAsync( + "ta_GetAnalysis", livePriceReq, TimeSpan.FromSeconds(5)); + + var latestIndicator = taResult?.Indicators?.LastOrDefault(); + + var taInfo = new TechnicalContextInfo + { + Rsi = latestIndicator?.Rsi14?.ToString("F1") ?? "N/A", + SupertrendStatus = latestIndicator?.SupertrendDirection ?? "N/A", + Atr = latestIndicator?.Atr14?.ToString("F2") ?? "N/A", + Sma50 = (double?)latestIndicator?.Sma50, + Sma200 = (double?)latestIndicator?.Sma200, + DetectedPatterns = taResult?.Patterns?.Select(p => new PatternContextInfo + { + PatternName = p.Type, + BreakoutDirection = p.BreakoutSignal?.Direction, + TargetPrice = (double?)p.BreakoutSignal?.TargetPrice, + PotentialPercent = (double?)p.BreakoutSignal?.PotentialPercent + }).ToList() ?? new List() + }; + + var n8nReq = new N8nAnalysisRequestDto + { + RequestId = Guid.NewGuid().ToString("N"), + Timestamp = DateTime.UtcNow, + TriggerType = "HourlyMonitor", + TargetAsset = new TargetAssetInfo + { + Symbol = trade.Symbol, + Isin = trade.Isin, + Sector = trade.Sector + }, + MarketContext = new MarketContextInfo + { + Vix = trade.VixValue, + MarketRegime = trade.VixRegime.ToString() + }, + UserPreferences = new UserPreferencesInfo + { + InstrumentType = trade.InstrumentType, + TimeframeFormatted = trade.Timeframe + }, + TechnicalContext = taInfo + }; + + var aiResponse = await n8nService.EvaluateAssetAsync(n8nReq, cancellationToken); + if (aiResponse == null) + { + _logger.LogWarning("[{Channel}] AI evaluation returned null for {TradeId}. Skipping update.", + "AnalyzerChannel", trade.TradeId); + return; + } + + string newRecommendation = "Hold"; + string reasoning = aiResponse.AiReasoning; + decimal? newStopLoss = trade.StopLoss; + decimal? newTakeProfit = trade.TakeProfit; + + // Check for trend reversal + bool aiSuggestsShort = + string.Equals(aiResponse.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) || + string.Equals(aiResponse.SuggestedDirection, "Sell", StringComparison.OrdinalIgnoreCase); + bool aiSuggestsLong = + string.Equals(aiResponse.SuggestedDirection, "Long", StringComparison.OrdinalIgnoreCase) || + string.Equals(aiResponse.SuggestedDirection, "Buy", StringComparison.OrdinalIgnoreCase); + + if ((isLong && aiSuggestsShort) || (!isLong && aiSuggestsLong)) + { + newRecommendation = "Close"; + reasoning = + $"Trendwende detektiert: KI empfiehlt {aiResponse.SuggestedDirection}, Trade ist aber {(isLong ? "Long" : "Short")}."; + } + else if (string.Equals(aiResponse.AiDecision, "Reject", StringComparison.OrdinalIgnoreCase)) + { + newRecommendation = "Close"; + reasoning = $"Risiko zu hoch: KI empfiehlt Exit. ({aiResponse.AiReasoning})"; + } + else if (aiResponse.ExecutionPlan != null) + { + // Ratchet / Trailing Logic: StopLoss darf das Risiko nicht vergrößern! + if (aiResponse.ExecutionPlan.StopLoss > 0) + { + var proposedSl = aiResponse.ExecutionPlan.StopLoss; + if (isLong) + { + // Bei Long darf der StopLoss nur NACH OBEN angepasst werden + if (trade.StopLoss <= 0 || proposedSl > trade.StopLoss) + { + newStopLoss = proposedSl; + if (proposedSl > trade.StopLoss && trade.StopLoss > 0) newRecommendation = "AdjustSL"; + } + } + else + { + // Bei Short darf der StopLoss nur NACH UNTEN angepasst werden + if (trade.StopLoss <= 0 || proposedSl < trade.StopLoss) + { + newStopLoss = proposedSl; + if (proposedSl < trade.StopLoss && trade.StopLoss > 0) newRecommendation = "AdjustSL"; + } + } + } + + if (aiResponse.ExecutionPlan.TakeProfitTargets != null && + aiResponse.ExecutionPlan.TakeProfitTargets.Count > 0) + { + var proposedTp = aiResponse.ExecutionPlan.TakeProfitTargets[0]; + if (proposedTp > 0 && proposedTp != trade.TakeProfit) + { + newTakeProfit = proposedTp; + if (newRecommendation == "Hold") newRecommendation = "AdjustTP"; + } + } + } + + await SendUpdateAsync(trade, currentPrice, newRecommendation, reasoning, newStopLoss, newTakeProfit); + } + + private async Task SendUpdateAsync(TradeProposalDto trade, decimal currentPrice, string recommendation, + string reasoning, decimal? suggestedStopLoss = null, decimal? suggestedTakeProfit = null) + { + var update = new TradeHourlyUpdateDto + { + TradeId = trade.TradeId, + Recommendation = recommendation, + CurrentPrice = currentPrice, + SuggestedStopLoss = suggestedStopLoss, + SuggestedTakeProfit = suggestedTakeProfit, + VixValue = trade.VixValue, + Reasoning = reasoning, + Timestamp = DateTime.UtcNow + }; + + // Direktes Objekt-Publishing nutzen (ManagedMqttClient serialisiert typgerecht) + string topic = $"finlytic/trades/updates/{trade.Isin}"; + await _mqttClient.PublishAsync(topic, update); + + _logger.LogInformation( + "[{Channel}] Published trade update for {TradeId} to topic {Topic}. Recommendation: {Rec}, Reasoning: {Reason}", + "AnalyzerChannel", trade.TradeId, topic, recommendation, reasoning); + } + + private static int EstimateMaxHoldingDays(string timeframe) + { + if (string.IsNullOrWhiteSpace(timeframe)) return 14; // Default + + string tfLower = timeframe.ToLowerInvariant(); + int multiplier = 1; + + if (tfLower.Contains("woche") || tfLower.Contains("week")) multiplier = 7; + else if (tfLower.Contains("monat") || tfLower.Contains("month")) multiplier = 30; + else if (tfLower.Contains("jahr") || tfLower.Contains("year")) multiplier = 365; + + var numbers = new List(); + string currentNum = ""; + + foreach (char c in timeframe) + { + if (char.IsDigit(c)) + { + currentNum += c; + } + else if (currentNum.Length > 0) + { + if (int.TryParse(currentNum, out int n)) numbers.Add(n); + currentNum = ""; + } + } + + if (currentNum.Length > 0 && int.TryParse(currentNum, out int lastN)) numbers.Add(lastN); + + int maxNum = numbers.Count > 0 ? numbers.Max() : 14; + + if (maxNum == 0) maxNum = 14; + if (multiplier == 1 && maxNum < 3) maxNum = 3; // Mindestens 3 Tage Kulanz + + return maxNum * multiplier; + } +} \ No newline at end of file diff --git a/FinlyticAnalyzer/Services/IN8nEvaluationService.cs b/FinlyticAnalyzer/Services/IN8nEvaluationService.cs new file mode 100644 index 0000000..19ef27f --- /dev/null +++ b/FinlyticAnalyzer/Services/IN8nEvaluationService.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Models.Analyzer; + +namespace FinlyticAnalyzer.Services; + +public interface IN8nEvaluationService +{ + /// + /// Evaluates an asset asynchronously using N8n. + /// + Task EvaluateAssetAsync(N8nAnalysisRequestDto request, CancellationToken cancellationToken = default); +} diff --git a/FinlyticAnalyzer/Services/IThreeLayerFilterEngine.cs b/FinlyticAnalyzer/Services/IThreeLayerFilterEngine.cs new file mode 100644 index 0000000..4692c78 --- /dev/null +++ b/FinlyticAnalyzer/Services/IThreeLayerFilterEngine.cs @@ -0,0 +1,29 @@ +using FinlyticCore.Models.Analyzer; +using FinlyticCore.Dtos.News; + +namespace FinlyticAnalyzer.Services; + +public class FilterResult +{ + public bool Passed { get; set; } + public string RejectReason { get; set; } = string.Empty; + + public string Sector { get; set; } = string.Empty; + public string Symbol { get; set; } = string.Empty; + public string Isin { get; set; } = string.Empty; + + public double ImpactScore { get; set; } + public double ThresholdApplied { get; set; } + + public string RiskTolerance { get; set; } = "Moderate"; + public string Timeframe { get; set; } = "1D"; + public string InstrumentType { get; set; } = "Stock"; +} + +public interface IThreeLayerFilterEngine +{ + /// + /// Evaluates news based on market regime and returns a filter result. + /// + FilterResult EvaluateNews(NewsArticleDto newsEvent, VixMarketRegime regime); +} diff --git a/FinlyticAnalyzer/Services/IVixTrackerService.cs b/FinlyticAnalyzer/Services/IVixTrackerService.cs new file mode 100644 index 0000000..bea931d --- /dev/null +++ b/FinlyticAnalyzer/Services/IVixTrackerService.cs @@ -0,0 +1,28 @@ +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Models.Analyzer; + +namespace FinlyticAnalyzer.Services; + +public interface IVixTrackerService +{ + /// + /// Gets the current VIX value. + /// + decimal GetCurrentVix(); + + /// + /// Gets the current market regime based on VIX. + /// + VixMarketRegime GetCurrentRegime(); + + /// + /// Updates the VIX tracker with a new tick value. + /// + void UpdateVixFromTick(decimal vixValue); + + /// + /// Polls the VIX asynchronously and returns its value. + /// + Task PollVixAsync(CancellationToken cancellationToken = default); +} diff --git a/FinlyticAnalyzer/Services/IWinRateCalculator.cs b/FinlyticAnalyzer/Services/IWinRateCalculator.cs new file mode 100644 index 0000000..3499f70 --- /dev/null +++ b/FinlyticAnalyzer/Services/IWinRateCalculator.cs @@ -0,0 +1,11 @@ +using FinlyticCore.Models.Analyzer; + +namespace FinlyticAnalyzer.Services; + +public interface IWinRateCalculator +{ + /// + /// Calculates the win rate for a given sector and symbol under the specified market regime. + /// + double CalculateWinRate(string sector, string symbol, VixMarketRegime regime); +} diff --git a/FinlyticAnalyzer/Services/LogCategoryFilter.cs b/FinlyticAnalyzer/Services/LogCategoryFilter.cs new file mode 100644 index 0000000..1ba66f1 --- /dev/null +++ b/FinlyticAnalyzer/Services/LogCategoryFilter.cs @@ -0,0 +1,36 @@ +namespace FinlyticAnalyzer.Services; + +public enum LogCategory +{ + MqttHealthPing, + MqttGeneral, + AnalyzerAuto, + AnalyzerManual, + DatabaseOps, + General +} + +public static class LogCategoryFilter +{ + public static bool EnableLogMqttHealthPing { get; set; } = false; + public static bool EnableLogMqttGeneral { get; set; } = true; + public static bool EnableLogAnalyzerAuto { get; set; } = true; + public static bool EnableLogAnalyzerManual { get; set; } = true; + public static bool EnableLogDatabaseOps { get; set; } = true; + + /// + /// Checks if a given log category is enabled. + /// + public static bool IsEnabled(LogCategory category) + { + return category switch + { + LogCategory.MqttHealthPing => EnableLogMqttHealthPing, + LogCategory.MqttGeneral => EnableLogMqttGeneral, + LogCategory.AnalyzerAuto => EnableLogAnalyzerAuto, + LogCategory.AnalyzerManual => EnableLogAnalyzerManual, + LogCategory.DatabaseOps => EnableLogDatabaseOps, + _ => true + }; + } +} diff --git a/FinlyticAnalyzer/Services/N8nEvaluationService.cs b/FinlyticAnalyzer/Services/N8nEvaluationService.cs new file mode 100644 index 0000000..43cdb96 --- /dev/null +++ b/FinlyticAnalyzer/Services/N8nEvaluationService.cs @@ -0,0 +1,104 @@ +using System.Text.Json; +using FinlyticCore.Models.Analyzer; +using FinlyticCore.Util; + +namespace FinlyticAnalyzer.Services; + +public class N8nEvaluationService : IN8nEvaluationService +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly string _webhookUrl; + + public N8nEvaluationService(HttpClient httpClient, IConfiguration configuration, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + _webhookUrl = configuration["N8N:WebhookUrl"] ?? configuration["N8N__WebhookUrl"] ?? "https://n8n.kleidukos.me/webhook/gemini/analysis/auto"; + + // Timeout auf 45 Sekunden erhöht für komplexere LLM/Gemini Chains in n8n + _httpClient.Timeout = TimeSpan.FromSeconds(45); + } + + /// + /// Evaluates an asset asynchronously using N8n / Gemini workflows. + /// + public async Task EvaluateAssetAsync(N8nAnalysisRequestDto request, CancellationToken cancellationToken = default) + { + try + { + _logger.LogInformation("[{Channel}] Sending n8n AI Evaluation request {RequestId} for Asset {Symbol} (ISIN: {Isin}) to {Url}...", + "AnalyzerChannel", request.RequestId, request.TargetAsset.Symbol, request.TargetAsset.Isin, _webhookUrl); + + // Typsichere AOT-Serialisierung verwenden + using var content = JsonContent.Create( + request, + FinlyticJsonSerializerContext.Default.N8nAnalysisRequestDto); + + using var response = await _httpClient.PostAsync(_webhookUrl, content, cancellationToken); + + if (response.IsSuccessStatusCode) + { + var contentStr = await response.Content.ReadAsStringAsync(cancellationToken); + + if (string.IsNullOrWhiteSpace(contentStr) || contentStr.Trim() == "{}" || contentStr.Trim() == "[]") + { + _logger.LogWarning("[{Channel}] n8n Webhook returned an EMPTY response for Request {RequestId}. Flagging as AI Rejection (Too Risky).", "AnalyzerChannel", request.RequestId); + return CreateRejectionFallback(request, "Die KI (n8n/Gemini) stuft den Trade als zu riskant ein und empfiehlt keine Positionierung."); + } + + // N8n schickt Ergebnisse manchmal als JSON-Array [{...}] zurück + string jsonToDeserialize = contentStr.Trim(); + if (jsonToDeserialize.StartsWith('[') && jsonToDeserialize.EndsWith(']')) + { + using var doc = JsonDocument.Parse(jsonToDeserialize); + if (doc.RootElement.ValueKind == JsonValueKind.Array && doc.RootElement.GetArrayLength() > 0) + { + jsonToDeserialize = doc.RootElement[0].GetRawText(); + } + } + + var responseDto = JsonSerializer.Deserialize( + jsonToDeserialize, + FinlyticJsonSerializerContext.Default.N8nAnalysisResponseDto); + + if (responseDto != null && !string.IsNullOrWhiteSpace(responseDto.AiDecision)) + { + _logger.LogInformation("[{Channel}] Received n8n AI Response for Request {RequestId}: Decision={Decision}, Score={Score:F2}, Direction={Direction}, Timeframe={Timeframe}", + "AnalyzerChannel", request.RequestId, responseDto.AiDecision, responseDto.EvalScore, responseDto.SuggestedDirection, responseDto.SuggestedTimeframe); + + return responseDto; + } + } + else + { + _logger.LogWarning("[{Channel}] n8n Webhook returned HTTP {StatusCode} for Request {RequestId}", + "AnalyzerChannel", response.StatusCode, request.RequestId); + } + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogError(ex, "[{Channel}] Timeout waiting for n8n AI Evaluation Webhook (Request {RequestId}). Consider increasing timeout.", "AnalyzerChannel", request.RequestId); + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] Error calling n8n AI Evaluation Webhook for Request {RequestId}", "AnalyzerChannel", request.RequestId); + } + + return null; // Signals RPC/Service failure to caller + } + + private static N8nAnalysisResponseDto CreateRejectionFallback(N8nAnalysisRequestDto request, string reasoning) + { + return new N8nAnalysisResponseDto + { + RequestId = request.RequestId, + AiDecision = "Rejected", + EvalScore = 0.0, + SuggestedDirection = "NONE", + SuggestedRisk = request.UserPreferences?.RiskTolerance ?? "Moderate", + SuggestedTimeframe = request.UserPreferences?.TimeframeFormatted ?? "1D", + AiReasoning = reasoning + }; + } +} \ No newline at end of file diff --git a/FinlyticAnalyzer/Services/SettingsDbService.cs b/FinlyticAnalyzer/Services/SettingsDbService.cs new file mode 100644 index 0000000..b453945 --- /dev/null +++ b/FinlyticAnalyzer/Services/SettingsDbService.cs @@ -0,0 +1,113 @@ +using FinlyticAnalyzer.Database; +using FinlyticAnalyzer.Entities; +using Microsoft.EntityFrameworkCore; + +namespace FinlyticAnalyzer.Services; + +public interface ISettingsDbService +{ + /// + /// Gets the analyzer settings asynchronously. + /// + Task GetSettingsAsync(); + + /// + /// Saves the analyzer settings asynchronously. + /// + Task SaveSettingsAsync(AnalyzerSettingsEntity settings); + + /// + /// Updates settings from a dictionary asynchronously. + /// + Task UpdateSettingsFromDictionaryAsync(Dictionary dictionary); +} + +public class SettingsDbService : ISettingsDbService +{ + private readonly AnalyzerDbContext _context; + + public SettingsDbService(AnalyzerDbContext context) + { + _context = context; + } + + /// + /// Gets the analyzer settings asynchronously. + /// + public async Task GetSettingsAsync() + { + var settings = await _context.Settings.AsNoTracking().FirstOrDefaultAsync(); + if (settings == null) + { + settings = new AnalyzerSettingsEntity { Id = Guid.NewGuid() }; + _context.Settings.Add(settings); + await _context.SaveChangesAsync(); + _context.ChangeTracker.Clear(); + } + return settings; + } + + /// + /// Saves the analyzer settings asynchronously. + /// + public async Task SaveSettingsAsync(AnalyzerSettingsEntity settings) + { + var existing = await _context.Settings.FirstOrDefaultAsync(); + if (existing == null) + { + if (settings.Id == Guid.Empty) settings.Id = Guid.NewGuid(); + _context.Settings.Add(settings); + } + else + { + existing.ScanCronSchedule = settings.ScanCronSchedule; + existing.MinSignalScore = settings.MinSignalScore; + existing.EnableLogMqttHealthPing = settings.EnableLogMqttHealthPing; + existing.EnableLogMqttGeneral = settings.EnableLogMqttGeneral; + existing.EnableLogAnalyzerAuto = settings.EnableLogAnalyzerAuto; + existing.EnableLogAnalyzerManual = settings.EnableLogAnalyzerManual; + existing.EnableLogDatabaseOps = settings.EnableLogDatabaseOps; + existing.UpdatedAt = settings.UpdatedAt; + _context.Settings.Update(existing); + } + await _context.SaveChangesAsync(); + + // Synchronize in-memory static filter values + LogCategoryFilter.EnableLogMqttHealthPing = settings.EnableLogMqttHealthPing; + LogCategoryFilter.EnableLogMqttGeneral = settings.EnableLogMqttGeneral; + LogCategoryFilter.EnableLogAnalyzerAuto = settings.EnableLogAnalyzerAuto; + LogCategoryFilter.EnableLogAnalyzerManual = settings.EnableLogAnalyzerManual; + LogCategoryFilter.EnableLogDatabaseOps = settings.EnableLogDatabaseOps; + + return settings; + } + + /// + /// Updates settings from a dictionary asynchronously. + /// + public async Task UpdateSettingsFromDictionaryAsync(Dictionary dictionary) + { + var settings = await GetSettingsAsync(); + + foreach (var (key, value) in dictionary) + { + if (string.Equals(key, "ScanCronSchedule", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(value)) + settings.ScanCronSchedule = value.Trim(); + else if (string.Equals(key, "MinSignalScore", StringComparison.OrdinalIgnoreCase) && double.TryParse(value, out var score)) + settings.MinSignalScore = score; + else if (string.Equals(key, "EnableLog_MqttHealthPing", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b1)) + settings.EnableLogMqttHealthPing = b1; + else if (string.Equals(key, "EnableLog_MqttGeneral", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b2)) + settings.EnableLogMqttGeneral = b2; + else if (string.Equals(key, "EnableLog_AnalyzerAuto", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b3)) + settings.EnableLogAnalyzerAuto = b3; + else if (string.Equals(key, "EnableLog_AnalyzerManual", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b4)) + settings.EnableLogAnalyzerManual = b4; + else if (string.Equals(key, "EnableLog_DatabaseOps", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var b5)) + settings.EnableLogDatabaseOps = b5; + } + + settings.UpdatedAt = DateTime.UtcNow; + await SaveSettingsAsync(settings); + } +} diff --git a/FinlyticAnalyzer/Services/ThreeLayerFilterEngine.cs b/FinlyticAnalyzer/Services/ThreeLayerFilterEngine.cs new file mode 100644 index 0000000..5467b49 --- /dev/null +++ b/FinlyticAnalyzer/Services/ThreeLayerFilterEngine.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Concurrent; +using FinlyticCore.Dtos.News; +using FinlyticCore.Models.Analyzer; +using Microsoft.Extensions.Logging; + +namespace FinlyticAnalyzer.Services; + +public class ThreeLayerFilterEngine : IThreeLayerFilterEngine +{ + private readonly ILogger _logger; + private readonly ConcurrentDictionary _seenEvents = new(); + private DateTime _lastCleanupTime = DateTime.UtcNow; + + public ThreeLayerFilterEngine(ILogger logger) + { + _logger = logger; + } + + /// + /// Evaluates news strictly based on ISIN and dynamic VIX market regime. + /// + public FilterResult EvaluateNews(NewsArticleDto newsEvent, VixMarketRegime regime) + { + var result = new FilterResult(); + + // ------------------------------------------------------------- + // Layer 1: Relevance, ISIN & Deduplication + // ------------------------------------------------------------- + if (newsEvent == null || newsEvent.Id == Guid.Empty) + { + result.Passed = false; + result.RejectReason = "Layer 1: Missing or Empty NewsArticle / EventId"; + return result; + } + + string eventId = newsEvent.Id.ToString(); + var now = DateTime.UtcNow; + + // Safely clean up dictionary every 30 minutes + if ((now - _lastCleanupTime).TotalMinutes > 30 || _seenEvents.Count > 10000) + { + CleanupSeenEvents(now); + } + + // Deduplication check (keep history for 12 hours) + if (_seenEvents.TryGetValue(eventId, out var prevTime) && (now - prevTime).TotalHours < 12.0) + { + result.Passed = false; + result.RejectReason = "Layer 1: Duplicate EventId within 12h window"; + return result; + } + + _seenEvents[eventId] = now; + + string isin = string.Empty; + string assetName = string.Empty; + + // Extract parameters strictly from MatchedAssets + if (newsEvent.MatchedAssets != null && newsEvent.MatchedAssets.Count > 0) + { + var firstAsset = newsEvent.MatchedAssets[0]; + isin = !string.IsNullOrWhiteSpace(firstAsset.Isin) ? firstAsset.Isin.Trim().ToUpperInvariant() : string.Empty; + assetName = !string.IsNullOrWhiteSpace(firstAsset.Name) ? firstAsset.Name.Trim() : string.Empty; + } + + // Mandatory check: Must have a valid ISIN + if (string.IsNullOrWhiteSpace(isin)) + { + result.Passed = false; + result.RejectReason = "Layer 1: Missing mandatory ISIN for news item"; + return result; + } + + result.Isin = isin; + // Asset-Symbol fallback to ISIN, Name is mapped appropriately later + result.Symbol = isin; + result.Sector = "General"; // Will be enriched downstream via Fundamentals RPC if available + + // ------------------------------------------------------------- + // Layer 2: Impact & Dynamic VIX Threshold + // ------------------------------------------------------------- + double impactScore = newsEvent.Confidence ?? 0.75; + if (impactScore <= 0) impactScore = 0.75; + + double requiredThreshold = regime switch + { + VixMarketRegime.LowVol => 0.55, + VixMarketRegime.Normal => 0.65, + VixMarketRegime.HighVol => 0.80, + VixMarketRegime.Panic => 0.90, + _ => 0.65 + }; + + result.ImpactScore = impactScore; + result.ThresholdApplied = requiredThreshold; + + if (impactScore < requiredThreshold) + { + result.Passed = false; + result.RejectReason = $"Layer 2: Impact score ({impactScore:F2}) below dynamic VIX threshold ({requiredThreshold:F2}) for regime {regime}"; + _logger.LogInformation("[{Channel}] Event {EventId} (ISIN: {Isin}) rejected by Layer 2 filter. Impact: {Impact:F2}, Threshold: {Threshold:F2}, Regime: {Regime}", + "AnalyzerChannel", eventId, isin, impactScore, requiredThreshold, regime); + return result; + } + + // ------------------------------------------------------------- + // Layer 3: Dynamic Parameter & Risk Engine + // ------------------------------------------------------------- + result.RiskTolerance = regime switch + { + VixMarketRegime.Panic => "Conservative", + VixMarketRegime.HighVol => "Moderate", + _ => "Aggressive" + }; + + result.Timeframe = impactScore >= 0.85 ? "4H" : "1D"; + result.InstrumentType = regime == VixMarketRegime.Panic ? "Option" : "Stock"; + + result.Passed = true; + _logger.LogInformation("[{Channel}] Event {EventId} passed 3-Layer Filter for ISIN {Isin}. Impact: {Impact:F2}, Regime: {Regime}", + "AnalyzerChannel", eventId, result.Isin, impactScore, regime); + + return result; + } + + private void CleanupSeenEvents(DateTime now) + { + _lastCleanupTime = now; + foreach (var kv in _seenEvents) + { + if ((now - kv.Value).TotalHours > 12.0) + { + _seenEvents.TryRemove(kv.Key, out _); + } + } + } +} \ No newline at end of file diff --git a/FinlyticAnalyzer/Services/VixTrackerService.cs b/FinlyticAnalyzer/Services/VixTrackerService.cs new file mode 100644 index 0000000..ea1665f --- /dev/null +++ b/FinlyticAnalyzer/Services/VixTrackerService.cs @@ -0,0 +1,101 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Models.Analyzer; +using FinlyticCore.Services.Yahoo; +using Microsoft.Extensions.Logging; + +namespace FinlyticAnalyzer.Services; + +public class VixTrackerService : IVixTrackerService +{ + private readonly YahooFinanceClient _yahooClient; + private readonly ILogger _logger; + + private decimal _currentVix = 18.5m; // Default: Normal Regime + private VixMarketRegime _currentRegime = VixMarketRegime.Normal; + private readonly object _lock = new(); + + public VixTrackerService(YahooFinanceClient yahooClient, ILogger logger) + { + _yahooClient = yahooClient; + _logger = logger; + } + + public decimal GetCurrentVix() + { + lock (_lock) + { + return _currentVix; + } + } + + public VixMarketRegime GetCurrentRegime() + { + lock (_lock) + { + return _currentRegime; + } + } + + public void UpdateVixFromTick(decimal vixValue) + { + if (vixValue <= 0m) return; + + lock (_lock) + { + var oldRegime = _currentRegime; + var oldVix = _currentVix; + + _currentVix = vixValue; + _currentRegime = CalculateRegime(vixValue); + + if (oldRegime != _currentRegime) + { + _logger.LogWarning("[{Channel}] [VIX REGIME SHIFT] Markt-Regime gewechselt: {OldRegime} -> {NewRegime} (VIX: {Vix:F2})", + "AnalyzerChannel", oldRegime, _currentRegime, _currentVix); + } + else if (Math.Abs(oldVix - vixValue) >= 0.5m) + { + _logger.LogInformation("[{Channel}] VIX aktualisiert: {Vix:F2} (Regime: {Regime})", + "AnalyzerChannel", _currentVix, _currentRegime); + } + } + } + + public async Task PollVixAsync(CancellationToken cancellationToken = default) + { + try + { + var vix = await _yahooClient.GetLivePriceAsync("^VIX", cancellationToken); + + if (vix.HasValue && vix.Value > 0m) + { + UpdateVixFromTick(vix.Value); + return vix.Value; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Graceful shutdown + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[{Channel}] Fehler beim Abfragen von ^VIX über YahooFinanceClient. Nutze gecachten Wert {Vix}.", + "AnalyzerChannel", GetCurrentVix()); + } + + return GetCurrentVix(); + } + + private static VixMarketRegime CalculateRegime(decimal vix) + { + return vix switch + { + < 15.0m => VixMarketRegime.LowVol, + >= 15.0m and < 20.0m => VixMarketRegime.Normal, + >= 20.0m and < 30.0m => VixMarketRegime.HighVol, + _ => VixMarketRegime.Panic + }; + } +} \ No newline at end of file diff --git a/FinlyticAnalyzer/Services/WinRateCalculator.cs b/FinlyticAnalyzer/Services/WinRateCalculator.cs new file mode 100644 index 0000000..20d1c84 --- /dev/null +++ b/FinlyticAnalyzer/Services/WinRateCalculator.cs @@ -0,0 +1,73 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using FinlyticCore.Models.Analyzer; +using FinlyticCore.Models.Trades; +using Microsoft.Extensions.Logging; + +namespace FinlyticAnalyzer.Services; + +public class WinRateCalculator : IWinRateCalculator +{ + private readonly ILogger _logger; + private readonly string _feedbackDir; + + public WinRateCalculator(ILogger logger) + { + _logger = logger; + _feedbackDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "feedback"); + if (!Directory.Exists(_feedbackDir)) + { + Directory.CreateDirectory(_feedbackDir); + } + } + + /// + /// Calculates the win rate for a given sector and symbol under the specified market regime. + /// + public double CalculateWinRate(string sector, string symbol, VixMarketRegime regime) + { + try + { + if (!Directory.Exists(_feedbackDir)) return 65.0; + + var jsonFiles = Directory.GetFiles(_feedbackDir, "*.json", SearchOption.AllDirectories); + if (jsonFiles.Length == 0) return 65.0; + + int totalTrades = 0; + int winningTrades = 0; + + foreach (var file in jsonFiles) + { + var content = File.ReadAllText(file); + var records = JsonSerializer.Deserialize(content); + if (records == null || records.Length == 0) continue; + + var matching = records.Where(r => + string.Equals(r.Sector, sector, StringComparison.OrdinalIgnoreCase) && + r.VixRegime == regime).ToList(); + + foreach (var rec in matching) + { + totalTrades++; + if (rec.IsWin) winningTrades++; + } + } + + if (totalTrades > 0) + { + double calculatedWinRate = (double)winningTrades / totalTrades * 100.0; + _logger.LogInformation("[{Channel}] Calculated win-rate for Sector '{Sector}' in Regime '{Regime}': {WinRate:F1}% ({Wins}/{Total})", + "AnalyzerChannel", sector, regime, calculatedWinRate, winningTrades, totalTrades); + return Math.Round(calculatedWinRate, 1); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[{Channel}] Error reading feedback files for win-rate calculation. Falling back to default.", "AnalyzerChannel"); + } + + return 65.0; // Default baseline win-rate + } +} diff --git a/FinlyticAnalyzer/Util/AnalyzerMqttClient.cs b/FinlyticAnalyzer/Util/AnalyzerMqttClient.cs new file mode 100644 index 0000000..92c1d61 --- /dev/null +++ b/FinlyticAnalyzer/Util/AnalyzerMqttClient.cs @@ -0,0 +1,804 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FinlyticAnalyzer.Database; +using FinlyticAnalyzer.Entities; +using FinlyticAnalyzer.Services; +using FinlyticCore.Dtos; +using FinlyticCore.Models; +using FinlyticCore.Models.Analyzer; +using FinlyticCore.Models.Trades; +using FinlyticCore.Util; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FinlyticAnalyzer.Util; + +/// +/// Unified Managed MQTT Client for FinlyticAnalyzer. +/// Handles event subscriptions, market screening, manual AI evaluation triggers, +/// and dispatches trade proposals via MQTT. +/// +public class AnalyzerMqttClient : ManagedMqttClient, IHostedService +{ + private readonly IConfiguration _configuration; + private readonly IServiceScopeFactory _scopeFactory; + private readonly IVixTrackerService _vixTracker; + private readonly IThreeLayerFilterEngine _filterEngine; + private readonly IWinRateCalculator _winRateCalculator; + private readonly IN8nEvaluationService _n8nService; + private readonly ILogger _logger; + + public AnalyzerMqttClient( + IConfiguration configuration, + IServiceScopeFactory scopeFactory, + IVixTrackerService vixTracker, + IThreeLayerFilterEngine filterEngine, + IWinRateCalculator winRateCalculator, + IN8nEvaluationService n8nService, + ILogger logger) : base(logger) + { + _configuration = configuration; + _scopeFactory = scopeFactory; + _vixTracker = vixTracker; + _filterEngine = filterEngine; + _winRateCalculator = winRateCalculator; + _n8nService = n8nService; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var config = new MqttConfiguration + { + Host = _configuration["MQTT:Host"] ?? _configuration["MQTT__Host"] ?? "localhost", + Port = Convert.ToInt32(_configuration["MQTT:Port"] ?? _configuration["MQTT__Port"] ?? "1883"), + Username = _configuration["MQTT:Username"] ?? _configuration["MQTT__Username"], + Password = _configuration["MQTT:Password"] ?? _configuration["MQTT__Password"], + 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); + await ConnectAsync(config); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("[{Channel}] Stopping Unified Analyzer MQTT Client.", "AnalyzerChannel"); + await DisconnectAsync(); + } + + protected override async Task OnConnectedAsync() + { + _logger.LogInformation("[{Channel}] Analyzer MQTT Client connected. Subscribing to topics and RPC response channels...", "AnalyzerChannel"); + + // Incoming Event Topics + await SubscribeAsync("services/news/completed"); + await SubscribeAsync("services/news/#"); + await SubscribeAsync("finlytic/news/raw/#"); + await SubscribeAsync("finlytic/market/ticks/#"); + await SubscribeAsync("services/config/updated/#"); + await SubscribeAsync("services/request/health_Ping/#"); + await SubscribeAsync("services/request/analyzer_TriggerManual/#"); + 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/trades_Get/#"); + await SubscribeAsync("services/response/tr_GetLivePrice/#"); + + _logger.LogInformation("[{Channel}] Successfully subscribed to all event and RPC channels.", "AnalyzerChannel"); + } + + protected override async Task OnMessageReceivedAsync(string topic, string payloadStr) + { + try + { + if (topic.Contains("health_Ping", StringComparison.OrdinalIgnoreCase)) + { + var segments = topic.Split('/'); + bool isForMe = segments.Length >= 5 + ? segments[3].Equals("FinlyticAnalyzer", StringComparison.OrdinalIgnoreCase) + : topic.Contains("FinlyticAnalyzer", StringComparison.OrdinalIgnoreCase); + + if (isForMe) + { + var correlationId = segments[^1]; + 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); + } + } + return; + } + + if (topic.StartsWith("services/config/updated", StringComparison.OrdinalIgnoreCase)) + { + 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(); + await settingsDb.UpdateSettingsFromDictionaryAsync(configUpdate.Settings); + _logger.LogInformation("[{Channel}] [AnalyzerMqttClient] Persisted {Count} updated settings to FinlyticAnalyzer database.", "AnalyzerChannel", configUpdate.Settings.Count); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] [AnalyzerMqttClient] Error processing MQTT config update event.", "AnalyzerChannel"); + } + } + return; + } + + if (topic.StartsWith("finlytic/market/ticks/")) + { + ProcessTickMessage(topic, payloadStr); + } + else if (topic.StartsWith("finlytic/news/raw/", StringComparison.OrdinalIgnoreCase) || + topic.StartsWith("services/news/", StringComparison.OrdinalIgnoreCase)) + { + await ProcessNewsMessageAsync(payloadStr, CancellationToken.None); + } + else if (topic.StartsWith("services/request/analyzer_TriggerManual/")) + { + var correlationId = topic.Split('/').Last(); + await HandleManualTriggerAsync(correlationId, payloadStr, CancellationToken.None); + } + else if (topic.StartsWith("finlytic/trades/closed/")) + { + await HandleClosedTradeFeedbackAsync(payloadStr); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] Error processing incoming MQTT message on topic {Topic}", "AnalyzerChannel", topic); + } + } + + private async Task HandleClosedTradeFeedbackAsync(string payloadStr) + { + try + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var closedDto = JsonSerializer.Deserialize(payloadStr, options); + + if (closedDto != null && !string.IsNullOrWhiteSpace(closedDto.TradeId)) + { + bool isWin = closedDto.Status.Contains("Profit", StringComparison.OrdinalIgnoreCase) || + closedDto.Status.Contains("Win", StringComparison.OrdinalIgnoreCase); + + var feedback = new TradeFeedbackRecord + { + TradeId = closedDto.TradeId, + AnalysisId = closedDto.AnalysisId, + Sector = closedDto.Sector, + Symbol = closedDto.Symbol, + Isin = closedDto.Isin, + EntryPrice = closedDto.EntryPrice, + StopLoss = closedDto.StopLoss, + TakeProfit = closedDto.TakeProfit, + IsWin = isWin, + VixRegime = closedDto.VixRegime, + VixValue = closedDto.VixValue, + CreatedAt = closedDto.CreatedAt, + ClosedAt = DateTime.UtcNow + }; + + string feedbackDir = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "feedback"); + if (!System.IO.Directory.Exists(feedbackDir)) + { + System.IO.Directory.CreateDirectory(feedbackDir); + } + + 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); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] Error processing closed trade feedback.", "AnalyzerChannel"); + } + } + + private async Task HandleManualTriggerAsync(string correlationId, string payloadStr, CancellationToken cancellationToken) + { + 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"); + 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); + } + + using var scope = _scopeFactory.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var regime = _vixTracker.GetCurrentRegime(); + var currentVix = _vixTracker.GetCurrentVix(); + string analysisId = Guid.NewGuid().ToString("N"); + double winRate = _winRateCalculator.CalculateWinRate(manualReq.Sector, manualReq.Symbol, regime); + + string riskLabel = manualReq.RiskScore > 70 ? $"Aggressiv ({manualReq.RiskScore}/100)" : (manualReq.RiskScore > 30 ? $"Balanced ({manualReq.RiskScore}/100)" : $"Konservativ ({manualReq.RiskScore}/100)"); + string timeframeFormatted = $"{manualReq.MinTimeframeValue}-{manualReq.MaxTimeframeValue} {manualReq.TimeframeUnit}"; + + var n8nRequest = new N8nAnalysisRequestDto + { + RequestId = analysisId, + Timestamp = DateTime.UtcNow, + TriggerType = "Manual", + TargetAsset = new TargetAssetInfo + { + Symbol = manualReq.FundamentalsData?.Ticker ?? manualReq.Symbol.ToUpperInvariant(), + Name = manualReq.FundamentalsData?.CompanyName ?? manualReq.Isin.ToUpperInvariant(), + Isin = manualReq.Isin.ToUpperInvariant(), + Sector = manualReq.Sector + }, + MarketContext = new MarketContextInfo + { + Vix = currentVix, + MarketRegime = regime.ToString() + }, + FilterContext = new FilterContextInfo + { + ImpactScore = 1.0, + RawNewsHeadline = string.IsNullOrWhiteSpace(manualReq.Headline) ? "Manual User Trigger" : manualReq.Headline + }, + UserPreferences = new UserPreferencesInfo + { + RiskScore = manualReq.RiskScore, + RiskTolerance = riskLabel, + MinTimeframeValue = manualReq.MinTimeframeValue, + MaxTimeframeValue = manualReq.MaxTimeframeValue, + TimeframeUnit = manualReq.TimeframeUnit, + TimeframeFormatted = timeframeFormatted, + InstrumentType = manualReq.InstrumentType, + UserNotes = manualReq.UserNotes + }, + TradeFeedback = new TradeFeedbackInfo + { + TotalAssetTrades = 0, + AssetWinRate = winRate, + AvgReturnPercent = 0.0, + LastTradeResult = "UNKNOWN" + }, + TechnicalContext = new TechnicalContextInfo + { + Rsi = manualReq.TaData?.Indicators?.LastOrDefault()?.Rsi14?.ToString("F1") ?? "N/A", + SupertrendStatus = manualReq.TaData?.Indicators?.LastOrDefault()?.SupertrendDirection ?? "NEUTRAL", + Atr = manualReq.TaData?.Indicators?.LastOrDefault()?.Atr14?.ToString("F2") ?? "N/A", + Sma50 = (double?)manualReq.TaData?.Indicators?.LastOrDefault()?.Sma50, + Sma200 = (double?)manualReq.TaData?.Indicators?.LastOrDefault()?.Sma200, + DetectedPatterns = manualReq.TaData?.Patterns?.Select(p => new PatternContextInfo + { + PatternName = p.Type, + BreakoutDirection = p.BreakoutSignal?.Direction, + TargetPrice = (double?)p.BreakoutSignal?.TargetPrice, + PotentialPercent = (double?)p.BreakoutSignal?.PotentialPercent + }).ToList() ?? new List() + }, + SentimentContext = new SentimentContextInfo + { + AssetSentimentScore = manualReq.SentimentData?.CurrentSummary?.CompoundScore ?? 0.0, + SectorSentimentScore = 0.0, + NewsSentimentSummary = manualReq.SentimentData?.CurrentSummary?.SentimentLabel ?? "Neutral" + }, + FundamentalContext = new FundamentalContextInfo + { + PeRatio = (double?)manualReq.FundamentalsData?.PeRatioTrailing, + ForwardPeRatio = (double?)manualReq.FundamentalsData?.PeRatioForward, + PegRatio = (double?)manualReq.FundamentalsData?.PegRatio, + MarketCap = (double?)manualReq.FundamentalsData?.MarketCapitalization, + DebtToEquity = (double?)manualReq.FundamentalsData?.DebtToEquity, + GrossMargin = (double?)manualReq.FundamentalsData?.GrossMargin, + NetProfitMargin = (double?)manualReq.FundamentalsData?.NetProfitMargin, + ReturnOnEquity = (double?)manualReq.FundamentalsData?.ReturnOnEquity, + DividendYield = (double?)manualReq.FundamentalsData?.DividendYield, + ShortPercentOfFloat = (double?)manualReq.FundamentalsData?.ShortPercentOfFloat, + AnalystTargetMedian = (double?)manualReq.FundamentalsData?.PriceTargetMedian, + EvToEbitda = (double?)manualReq.FundamentalsData?.EvToEbitda + } + }; + + var n8nResponse = await _n8nService.EvaluateAssetAsync(n8nRequest, cancellationToken); + + var settingsService = scope.ServiceProvider.GetRequiredService(); + var settings = await settingsService.GetSettingsAsync(); + double minSignalScore = settings.MinSignalScore; + + double confidenceScore = n8nResponse?.EvalScore > 0 ? n8nResponse.EvalScore : 0.75; + bool shouldProceed = n8nResponse != null && + string.Equals(n8nResponse.AiDecision, "Proceed", StringComparison.OrdinalIgnoreCase) && + (confidenceScore * 100.0) >= minSignalScore && + winRate >= minSignalScore; + + TradeProposalDto? proposalDto = null; + if (n8nResponse != null) + { + proposalDto = new TradeProposalDto + { + TradeId = "PROP-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant(), + AnalysisId = analysisId, + EventId = analysisId, + Sector = manualReq.Sector, + Symbol = manualReq.Symbol.ToUpperInvariant(), + Isin = manualReq.Isin.ToUpperInvariant(), + CompanyName = manualReq.FundamentalsData?.CompanyName ?? manualReq.Symbol, + EntryPrice = manualReq.CurrentPrice, + SignalType = string.Equals(n8nResponse.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) ? "SELL" : "BUY", + Status = shouldProceed ? "Proposed" : "Rejected", + RiskTolerance = n8nResponse.SuggestedRisk, + Timeframe = timeframeFormatted, + InstrumentType = manualReq.InstrumentType, + WinRate = winRate, + VixRegime = regime, + VixValue = currentVix, + TtlMinutes = 60, + Reasoning = $"Manual n8n Evaluation ({n8nResponse.AiDecision}): {n8nResponse.AiReasoning}", + + StopLoss = n8nResponse.ExecutionPlan?.StopLoss ?? 0, + TakeProfit = n8nResponse.ExecutionPlan?.TakeProfitTargets != null && n8nResponse.ExecutionPlan.TakeProfitTargets.Count > 0 ? n8nResponse.ExecutionPlan.TakeProfitTargets[0] : 0, + EntryZoneMin = n8nResponse.ExecutionPlan?.EntryZone?.Min, + EntryZoneMax = n8nResponse.ExecutionPlan?.EntryZone?.Max, + TakeProfitTargets = n8nResponse.ExecutionPlan?.TakeProfitTargets, + RiskRewardRatio = n8nResponse.ExecutionPlan?.RiskRewardRatio, + MaxLeverage = n8nResponse.ExecutionPlan?.MaxLeverage, + TechnicalRationale = n8nResponse.DetailedAnalysis?.TechnicalRationale ?? string.Empty, + FundamentalRationale = n8nResponse.DetailedAnalysis?.FundamentalRationale ?? string.Empty, + RiskWarning = n8nResponse.DetailedAnalysis?.RiskWarning ?? string.Empty, + + CreatedAt = DateTime.UtcNow + }; + } + + var analysisEntity = new AnalysisEntity + { + AnalysisId = analysisId, + EventId = analysisId, + Sector = manualReq.Sector, + Symbol = manualReq.Symbol.ToUpperInvariant(), + Isin = manualReq.Isin.ToUpperInvariant(), + VixRegime = regime, + VixValue = currentVix, + ImpactScore = 1.0, + WinRate = winRate, + RawDataJson = JsonSerializer.Serialize(manualReq), + AiOutputJson = proposalDto != null ? JsonSerializer.Serialize(proposalDto) : "{}", + N8nResponseJson = n8nResponse != null ? JsonSerializer.Serialize(n8nResponse) : "{}", + N8nEvalScore = n8nResponse?.EvalScore ?? 0, + N8nDecision = n8nResponse?.AiDecision ?? "Rejected", + IsTradeProposed = shouldProceed, + CreatedAt = DateTime.UtcNow + }; + + dbContext.Analyses.Add(analysisEntity); + await dbContext.SaveChangesAsync(cancellationToken); + + var responseTopic = $"services/response/analyzer_TriggerManual/{correlationId}"; + var responsePayload = new ManualAnalysisResponseDto + { + AnalysisId = analysisId, + IsTradeProposed = shouldProceed, + Status = shouldProceed ? "Success" : "Rejected", + Recommendation = shouldProceed ? "RECOMMENDED" : "NOT_RECOMMENDED", + N8nResponse = n8nResponse, + Proposal = proposalDto + }; + + await PublishAsync(responseTopic, responsePayload); + + if (proposalDto != null && shouldProceed) + { + 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); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] Failed to handle manual trigger.", "AnalyzerChannel"); + } + } + + private void ProcessTickMessage(string topic, string payloadStr) + { + if (topic.EndsWith("VIX", StringComparison.OrdinalIgnoreCase) || topic.EndsWith("^VIX", StringComparison.OrdinalIgnoreCase)) + { + try + { + var tick = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.TickMessageDto); + if (tick != null && tick.Price > 0) + { + _vixTracker.UpdateVixFromTick(tick.Price); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[{Channel}] Failed to parse VIX tick message.", "AnalyzerChannel"); + } + } + } + + private async Task ProcessNewsMessageAsync(string payloadStr, CancellationToken cancellationToken) + { + var newsArticle = JsonSerializer.Deserialize(payloadStr, FinlyticJsonSerializerContext.Default.NewsArticleDto); + if (newsArticle == null) return; + + var regime = _vixTracker.GetCurrentRegime(); + var currentVix = _vixTracker.GetCurrentVix(); + + 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); + } + return; + } + + if (LogCategoryFilter.IsEnabled(LogCategory.AnalyzerAuto)) + { + _logger.LogInformation("[{Channel}] [AutoScreener] [TRIGGERED] Screening market opportunity for ISIN '{Isin}'...", "AnalyzerChannel", filterResult.Isin); + } + + string analysisId = Guid.NewGuid().ToString("N"); + string eventId = newsArticle.Id != Guid.Empty ? newsArticle.Id.ToString() : analysisId; + string rawHeadline = newsArticle.Title ?? string.Empty; + + double winRate = _winRateCalculator.CalculateWinRate(filterResult.Sector, filterResult.Symbol, regime); + + int riskScore = 50; + string riskTolerance = "Balanced (50/100)"; + int minTf = 4; + int maxTf = 7; + + if (winRate < 45.0) + { + riskScore = 30; + riskTolerance = "Konservativ (30/100)"; + minTf = 7; + maxTf = 14; + } + else if (winRate >= 65.0) + { + riskScore = 75; + riskTolerance = "Aggressiv (75/100)"; + minTf = 1; + maxTf = 4; + } + + TechnicalContextInfo taInfo = new(); + FundamentalContextInfo fundInfo = new(); + SentimentContextInfo sentInfo = new(); + + string resolvedSymbol = filterResult.Symbol; + string resolvedName = filterResult.Symbol; + + if (newsArticle.MatchedAssets != null && newsArticle.MatchedAssets.Count > 0) + { + var firstAsset = newsArticle.MatchedAssets[0]; + if (!string.IsNullOrWhiteSpace(firstAsset.Name)) + { + resolvedName = firstAsset.Name; + if (resolvedSymbol == "UNKNOWN" || resolvedSymbol == filterResult.Isin) + { + resolvedSymbol = resolvedName; + } + } + } + + FinlyticCore.Dtos.TechnicalAnalysis.TechnicalAnalysisDto? taResp = null; + FinlyticCore.Dtos.Fundamentals.AssetFundamentalsDto? fundResp = null; + FinlyticCore.Dtos.TechnicalAnalysis.LivePriceDto? livePriceResp = null; + + try + { + if (IsConnected) + { + var isinReq = new IsinRequest(filterResult.Isin); + + livePriceResp = await SendRpcRequestAsync( + "tr_GetLivePrice", isinReq, TimeSpan.FromSeconds(3)); + + taResp = await SendRpcRequestAsync( + "ta_GetAnalysis", isinReq, TimeSpan.FromSeconds(3)); + if (taResp?.Indicators != null) + { + var latestIndicator = taResp.Indicators.LastOrDefault(); + taInfo = new TechnicalContextInfo + { + Rsi = latestIndicator?.Rsi14?.ToString("F1") ?? "50.0", + SupertrendStatus = latestIndicator?.SupertrendDirection ?? "NEUTRAL", + Atr = latestIndicator?.Atr14?.ToString("F2") ?? "0.0", + Sma50 = (double?)latestIndicator?.Sma50, + Sma200 = (double?)latestIndicator?.Sma200, + DetectedPatterns = taResp.Patterns?.Select(p => new PatternContextInfo + { + PatternName = p.Type, + BreakoutDirection = p.BreakoutSignal?.Direction, + TargetPrice = (double?)p.BreakoutSignal?.TargetPrice, + PotentialPercent = (double?)p.BreakoutSignal?.PotentialPercent + }).ToList() ?? new List() + }; + } + + fundResp = await SendRpcRequestAsync( + "fundamentals_Get", isinReq, TimeSpan.FromSeconds(3)); + if (fundResp != null) + { + resolvedSymbol = !string.IsNullOrWhiteSpace(fundResp.Ticker) ? fundResp.Ticker : resolvedSymbol; + resolvedName = !string.IsNullOrWhiteSpace(fundResp.CompanyName) ? fundResp.CompanyName : resolvedName; + + fundInfo = new FundamentalContextInfo + { + PeRatio = (double?)fundResp.PeRatioTrailing, + ForwardPeRatio = (double?)fundResp.PeRatioForward, + PegRatio = (double?)fundResp.PegRatio, + MarketCap = (double?)fundResp.MarketCapitalization, + DebtToEquity = (double?)fundResp.DebtToEquity, + GrossMargin = (double?)fundResp.GrossMargin, + NetProfitMargin = (double?)fundResp.NetProfitMargin, + ReturnOnEquity = (double?)fundResp.ReturnOnEquity, + DividendYield = (double?)fundResp.DividendYield, + ShortPercentOfFloat = (double?)fundResp.ShortPercentOfFloat, + AnalystTargetMedian = (double?)fundResp.PriceTargetMedian, + EvToEbitda = (double?)fundResp.EvToEbitda + }; + } + + var sentResp = await SendRpcRequestAsync( + "sentiment_GetIsin", isinReq, TimeSpan.FromSeconds(3)); + if (sentResp != null) + { + sentInfo = new SentimentContextInfo + { + AssetSentimentScore = sentResp.CurrentSummary?.CompoundScore ?? 0.0, + SectorSentimentScore = 0.5, + NewsSentimentSummary = sentResp.CurrentSummary?.SentimentLabel ?? "Neutral" + }; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[{Channel}] Failed to fetch context data for auto screener analysis.", "AnalyzerChannel"); + } + + var n8nRequest = new N8nAnalysisRequestDto + { + RequestId = analysisId, + Timestamp = DateTime.UtcNow, + TriggerType = "AutoScreener", + TargetAsset = new TargetAssetInfo + { + Symbol = resolvedSymbol.ToUpperInvariant(), + Name = resolvedName, + Isin = filterResult.Isin.ToUpperInvariant(), + Sector = filterResult.Sector + }, + MarketContext = new MarketContextInfo + { + Vix = currentVix, + MarketRegime = regime.ToString() + }, + FilterContext = new FilterContextInfo + { + ImpactScore = filterResult.ImpactScore, + RawNewsHeadline = rawHeadline + }, + UserPreferences = new UserPreferencesInfo + { + RiskScore = riskScore, + RiskTolerance = riskTolerance, + MinTimeframeValue = minTf, + MaxTimeframeValue = maxTf, + TimeframeUnit = "Tage", + TimeframeFormatted = $"{minTf}-{maxTf} Tage", + InstrumentType = "KnockOut", + UserNotes = "High-Conviction Screener Mode: Evaluate underlying data for strong reliable chart moves." + }, + TradeFeedback = new TradeFeedbackInfo + { + TotalAssetTrades = 0, + AssetWinRate = winRate, + AvgReturnPercent = 0.0, + LastTradeResult = "UNKNOWN" + }, + TechnicalContext = taInfo, + SentimentContext = sentInfo, + FundamentalContext = fundInfo + }; + + var n8nResponse = await _n8nService.EvaluateAssetAsync(n8nRequest, cancellationToken); + + double minSignalScore = 75.0; + using (var scope = _scopeFactory.CreateScope()) + { + var settingsService = scope.ServiceProvider.GetRequiredService(); + var settings = await settingsService.GetSettingsAsync(); + minSignalScore = settings.MinSignalScore; + } + + double confidenceScore = n8nResponse?.EvalScore > 0 ? n8nResponse.EvalScore : 0.75; + bool isHighConviction = n8nResponse != null && + string.Equals(n8nResponse.AiDecision, "Proceed", StringComparison.OrdinalIgnoreCase) && + (confidenceScore * 100.0) >= minSignalScore && + winRate >= minSignalScore; + + string finalSymbol = !string.IsNullOrWhiteSpace(resolvedSymbol) && resolvedSymbol != "UNKNOWN" + ? resolvedSymbol + : (!string.IsNullOrWhiteSpace(filterResult.Symbol) && filterResult.Symbol != "UNKNOWN" ? filterResult.Symbol : filterResult.Isin); + + string finalName = !string.IsNullOrWhiteSpace(resolvedName) && resolvedName != "UNKNOWN" + ? resolvedName + : finalSymbol; + + string marketRegion = filterResult.Isin.StartsWith("DE", StringComparison.OrdinalIgnoreCase) ? "GERMAN_EQUITIES" : "US_EQUITIES"; + + var supportLevels = new List(); + var resistanceLevels = new List(); + + double currentPrice = (double)(livePriceResp?.CurrentPrice > 0 ? livePriceResp.CurrentPrice : (fundResp?.CurrentPrice > 0 ? fundResp.CurrentPrice : 0.0m)); + if (currentPrice > 0) + { + supportLevels.Add(Math.Round(currentPrice * 0.98, 2)); + supportLevels.Add(Math.Round(currentPrice * 0.95, 2)); + resistanceLevels.Add(Math.Round(currentPrice * 1.03, 2)); + resistanceLevels.Add(Math.Round(currentPrice * 1.06, 2)); + } + + if (n8nResponse?.ExecutionPlan?.EntryZone != null) + { + if (n8nResponse.ExecutionPlan.EntryZone.Min > 0) supportLevels.Insert(0, (double)n8nResponse.ExecutionPlan.EntryZone.Min); + if (n8nResponse.ExecutionPlan.EntryZone.Max > 0) resistanceLevels.Insert(0, (double)n8nResponse.ExecutionPlan.EntryZone.Max); + } + + var recommendation = new AssetRecommendationDto + { + Mode = "AUTO_SCREENER", + Timestamp = DateTime.UtcNow, + RecommendedAsset = new RecommendedAssetInfo + { + Symbol = finalSymbol, + CompanyName = finalName, + Isin = filterResult.Isin, + Market = marketRegion, + Bias = string.Equals(n8nResponse?.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) ? "BEARISH" : "BULLISH", + ConfidenceScore = Math.Round(confidenceScore, 2), + Timeframe = !string.IsNullOrWhiteSpace(n8nResponse?.SuggestedTimeframe) ? n8nResponse.SuggestedTimeframe : "1D" + }, + Rationale = new RecommendationRationaleInfo + { + PatternDetected = taInfo.DetectedPatterns?.Count > 0 + ? string.Join(", ", taInfo.DetectedPatterns.Select(p => p.PatternName)) + : (!string.IsNullOrWhiteSpace(n8nResponse?.DetailedAnalysis?.TechnicalRationale) ? n8nResponse.DetailedAnalysis.TechnicalRationale : "Multi-Timeframe Trend & Volume Confluence"), + VixContext = $"VIX at {currentVix:F1} ({regime} volatility environment)", + KeyTechnicalLevels = new KeyTechnicalLevelsInfo + { + Support = supportLevels.Distinct().ToList(), + Resistance = resistanceLevels.Distinct().ToList() + }, + Summary = !string.IsNullOrWhiteSpace(n8nReasoning(n8nResponse)) + ? n8nResponse!.AiReasoning + : "High conviction setup based on multi-timeframe technical confluence, sentiment, and fundamental data." + }, + ActionRequired = isHighConviction ? "PROMPT_USER_FOR_MANUAL_TRADE" : "NO_ACTION" + }; + + using (var scope = _scopeFactory.CreateScope()) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var analysisEntity = new AnalysisEntity + { + AnalysisId = analysisId, + EventId = eventId, + Sector = filterResult.Sector, + Symbol = finalSymbol, + Isin = filterResult.Isin, + VixRegime = regime, + VixValue = currentVix, + ImpactScore = filterResult.ImpactScore, + WinRate = winRate, + 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 + { + TradeId = "PROP-" + Guid.NewGuid().ToString("N")[..10].ToUpperInvariant(), + AnalysisId = analysisId, + EventId = eventId, + Sector = filterResult.Sector, + Symbol = finalSymbol, + Isin = filterResult.Isin, + CompanyName = finalName, + EntryPrice = (decimal)currentPrice, + SignalType = string.Equals(n8nResponse.SuggestedDirection, "Short", StringComparison.OrdinalIgnoreCase) ? "SELL" : "BUY", + Status = "Proposed", + RiskTolerance = n8nResponse.SuggestedRisk ?? "Balanced", + Timeframe = $"{minTf}-{maxTf} Tage", + InstrumentType = "KnockOut", + WinRate = winRate, + VixRegime = regime, + VixValue = currentVix, + TtlMinutes = 180, + Reasoning = n8nResponse.AiReasoning ?? "Auto-Screener High Conviction Trade", + StopLoss = n8nResponse.ExecutionPlan?.StopLoss ?? 0, + TakeProfit = n8nResponse.ExecutionPlan?.TakeProfitTargets != null && n8nResponse.ExecutionPlan.TakeProfitTargets.Count > 0 ? n8nResponse.ExecutionPlan.TakeProfitTargets[0] : 0, + EntryZoneMin = n8nResponse.ExecutionPlan?.EntryZone?.Min, + EntryZoneMax = n8nResponse.ExecutionPlan?.EntryZone?.Max, + TakeProfitTargets = n8nResponse.ExecutionPlan?.TakeProfitTargets, + RiskRewardRatio = n8nResponse.ExecutionPlan?.RiskRewardRatio, + MaxLeverage = n8nResponse.ExecutionPlan?.MaxLeverage, + TechnicalRationale = n8nResponse.DetailedAnalysis?.TechnicalRationale ?? string.Empty, + FundamentalRationale = n8nResponse.DetailedAnalysis?.FundamentalRationale ?? string.Empty, + RiskWarning = n8nResponse.DetailedAnalysis?.RiskWarning ?? string.Empty, + CreatedAt = DateTime.UtcNow + }; + + 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); + } + + if (isHighConviction) + { + string recTopic = $"finlytic/recommendations/auto/{(string.IsNullOrWhiteSpace(filterResult.Sector) ? "general" : filterResult.Sector.ToLowerInvariant())}/{finalSymbol.ToLowerInvariant()}"; + 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); + } + } + 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); + } + } + } + + private static string n8nReasoning(N8nAnalysisResponseDto? resp) => resp?.AiReasoning ?? string.Empty; +} \ No newline at end of file diff --git a/FinlyticAnalyzer/appsettings.json b/FinlyticAnalyzer/appsettings.json new file mode 100644 index 0000000..4f007b9 --- /dev/null +++ b/FinlyticAnalyzer/appsettings.json @@ -0,0 +1,16 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=finlytic_analyzer;Username=admin;Password=admin" + }, + "MQTT": { + "Host": "localhost", + "Port": "1883", + "ClientId": "finlytic_analyzer" + } +}