diff --git a/FinlyticSentiment/Database/SentimentDbContext.cs b/FinlyticSentiment/Database/SentimentDbContext.cs
index d380cbb..9a3fcf3 100644
--- a/FinlyticSentiment/Database/SentimentDbContext.cs
+++ b/FinlyticSentiment/Database/SentimentDbContext.cs
@@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore.Design;
namespace FinlyticSentiment.Database;
///
-/// EF Core DbContext for managing FinlyticSentiment settings in PostgreSQL.
+/// EF Core DbContext for managing FinlyticSentiment persistent entities and dynamic settings.
///
public class SentimentDbContext : DbContext, ISettingsDbContext
{
@@ -16,24 +16,50 @@ public class SentimentDbContext : DbContext, ISettingsDbContext
}
public DbSet DynamicSettings => Set();
- public DbSet Settings => Set();
+ public DbSet ArticleSentiments => Set();
+ public DbSet CompanySentiments => Set();
+ public DbSet SectorSentiments => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
+ // Dynamic Settings
modelBuilder.Entity(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.Key).IsUnique();
});
- modelBuilder.Entity(entity =>
+ // Individual Article Sentiments
+ modelBuilder.Entity(entity =>
{
- entity.ToTable("sentiment_settings");
entity.HasKey(e => e.Id);
- entity.Property(e => e.GermanWebhookUrl).HasMaxLength(500);
- entity.Property(e => e.EnglishWebhookUrl).HasMaxLength(500);
+
+ // Prevent duplicate analysis of the same article for the same asset
+ entity.HasIndex(e => new { e.ArticleId, e.Isin }).IsUnique();
+
+ // High-performance time-series index for timeline charts & time-decay scans
+ entity.HasIndex(e => new { e.Isin, e.PublishedAtUtc });
+
+ entity.HasIndex(e => e.AnalyzedAtUtc);
+ entity.HasIndex(e => e.Sector);
+ });
+
+ // Pre-Aggregated Company Sentiment Summaries
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Isin);
+ entity.HasIndex(e => e.Sector);
+ entity.HasIndex(e => e.WeightedScore);
+ entity.HasIndex(e => e.LastUpdatedUtc);
+ });
+
+ // Pre-Aggregated Sector Sentiment Summaries
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Sector);
+ entity.HasIndex(e => e.LastUpdatedUtc);
});
}
}
diff --git a/FinlyticSentiment/Dockerfile b/FinlyticSentiment/Dockerfile
index 4a00792..f8be037 100644
--- a/FinlyticSentiment/Dockerfile
+++ b/FinlyticSentiment/Dockerfile
@@ -1,5 +1,5 @@
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
-USER app
+USER $APP_UID
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
diff --git a/FinlyticSentiment/Entities/ArticleSentimentEntity.cs b/FinlyticSentiment/Entities/ArticleSentimentEntity.cs
new file mode 100644
index 0000000..7c0c16a
--- /dev/null
+++ b/FinlyticSentiment/Entities/ArticleSentimentEntity.cs
@@ -0,0 +1,53 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace FinlyticSentiment.Entities;
+
+///
+/// Entity representing a single sentiment evaluation of an asset mentioned in a news article.
+///
+[Table("article_sentiments")]
+public class ArticleSentimentEntity
+{
+ [Key]
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ [Required]
+ public Guid ArticleId { get; set; }
+
+ [Required]
+ [MaxLength(12)]
+ public string Isin { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(256)]
+ public string Name { get; set; } = string.Empty;
+
+ [MaxLength(128)]
+ public string? Sector { get; set; }
+
+ [Required]
+ [MaxLength(32)]
+ public string Label { get; set; } = "NEUTRAL";
+
+ public double CompoundScore { get; set; }
+
+ public double Confidence { get; set; }
+
+ [MaxLength(32)]
+ public string? Impact { get; set; }
+
+ public double PositiveProbability { get; set; }
+
+ public double NegativeProbability { get; set; }
+
+ public double NeutralProbability { get; set; }
+
+ [MaxLength(1024)]
+ public string? KeyHighlight { get; set; }
+
+ public DateTime PublishedAtUtc { get; set; }
+
+ public DateTime AnalyzedAtUtc { get; set; } = DateTime.UtcNow;
+}
diff --git a/FinlyticSentiment/Entities/CompanySentimentSummaryEntity.cs b/FinlyticSentiment/Entities/CompanySentimentSummaryEntity.cs
new file mode 100644
index 0000000..c4c6bf3
--- /dev/null
+++ b/FinlyticSentiment/Entities/CompanySentimentSummaryEntity.cs
@@ -0,0 +1,52 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace FinlyticSentiment.Entities;
+
+///
+/// Pre-aggregated, real-time sentiment summary for a company/asset, enabling sub-millisecond lookups without recalculation.
+///
+[Table("company_sentiment_summaries")]
+public class CompanySentimentSummaryEntity
+{
+ [Key]
+ [MaxLength(12)]
+ public string Isin { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(256)]
+ public string Name { get; set; } = string.Empty;
+
+ [MaxLength(128)]
+ public string? Sector { get; set; }
+
+ [Required]
+ [MaxLength(32)]
+ public string CurrentLabel { get; set; } = "NEUTRAL";
+
+ public double AverageScore { get; set; }
+
+ public double WeightedScore { get; set; }
+
+ public double AverageConfidence { get; set; }
+
+ public int TotalAnalysesCount { get; set; }
+
+ public int PositiveCount { get; set; }
+
+ public int NegativeCount { get; set; }
+
+ public int NeutralCount { get; set; }
+
+ [MaxLength(1024)]
+ public string? LatestKeyHighlight { get; set; }
+
+ [MaxLength(32)]
+ public string? Trend { get; set; } = "STABLE";
+
+ public DateTime LastUpdatedUtc { get; set; } = DateTime.UtcNow;
+
+ [ConcurrencyCheck]
+ public uint Version { get; set; }
+}
diff --git a/FinlyticSentiment/Entities/SectorSentimentSummaryEntity.cs b/FinlyticSentiment/Entities/SectorSentimentSummaryEntity.cs
new file mode 100644
index 0000000..0453b9b
--- /dev/null
+++ b/FinlyticSentiment/Entities/SectorSentimentSummaryEntity.cs
@@ -0,0 +1,28 @@
+using System;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace FinlyticSentiment.Entities;
+
+///
+/// Pre-aggregated real-time sentiment summary for a market sector (e.g. Technology, Healthcare).
+///
+[Table("sector_sentiment_summaries")]
+public class SectorSentimentSummaryEntity
+{
+ [Key]
+ [MaxLength(128)]
+ public string Sector { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(32)]
+ public string CurrentLabel { get; set; } = "NEUTRAL";
+
+ public double AverageScore { get; set; }
+
+ public int TotalArticlesCount { get; set; }
+
+ public int TotalCompaniesCount { get; set; }
+
+ public DateTime LastUpdatedUtc { get; set; } = DateTime.UtcNow;
+}
diff --git a/FinlyticSentiment/Entities/SentimentSettingsEntity.cs b/FinlyticSentiment/Entities/SentimentSettingsEntity.cs
deleted file mode 100644
index 6377eb0..0000000
--- a/FinlyticSentiment/Entities/SentimentSettingsEntity.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using System;
-
-namespace FinlyticSentiment.Entities;
-
-///
-/// Entity representing runtime operational settings for FinlyticSentiment stored in PostgreSQL.
-///
-public class SentimentSettingsEntity
-{
- public Guid Id { get; set; } = Guid.NewGuid();
-
- public double MinConfidenceScore { get; set; } = 0.70;
-
- public int MaxBatchSize { get; set; } = 10;
-
- public int SweepIntervalMinutes { get; set; } = 2;
-
- public string GermanWebhookUrl { get; set; } = "https://n8n.kleidukos.me/webhook/sentiment/de";
-
- public string EnglishWebhookUrl { get; set; } = "https://n8n.kleidukos.me/webhook/sentiment/en";
-
- public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
-}
diff --git a/FinlyticSentiment/Migrations/20260801090401_InitialSentimentSettings.Designer.cs b/FinlyticSentiment/Migrations/20260801090401_InitialSentimentSettings.Designer.cs
deleted file mode 100644
index febe352..0000000
--- a/FinlyticSentiment/Migrations/20260801090401_InitialSentimentSettings.Designer.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-//
-using System;
-using FinlyticSentiment.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 FinlyticSentiment.Migrations
-{
- [DbContext(typeof(SentimentDbContext))]
- [Migration("20260801090401_InitialSentimentSettings")]
- partial class InitialSentimentSettings
- {
- ///
- 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("FinlyticSentiment.Entities.SentimentSettingsEntity", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("uuid");
-
- b.Property("EnglishWebhookUrl")
- .IsRequired()
- .HasMaxLength(500)
- .HasColumnType("character varying(500)");
-
- b.Property("GermanWebhookUrl")
- .IsRequired()
- .HasMaxLength(500)
- .HasColumnType("character varying(500)");
-
- b.Property("MaxBatchSize")
- .HasColumnType("integer");
-
- b.Property("MinConfidenceScore")
- .HasColumnType("double precision");
-
- b.Property("SweepIntervalMinutes")
- .HasColumnType("integer");
-
- b.Property("UpdatedAt")
- .HasColumnType("timestamp with time zone");
-
- b.HasKey("Id");
-
- b.ToTable("sentiment_settings", (string)null);
- });
-#pragma warning restore 612, 618
- }
- }
-}
diff --git a/FinlyticSentiment/Migrations/20260801090401_InitialSentimentSettings.cs b/FinlyticSentiment/Migrations/20260801090401_InitialSentimentSettings.cs
deleted file mode 100644
index 3663991..0000000
--- a/FinlyticSentiment/Migrations/20260801090401_InitialSentimentSettings.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using System;
-using Microsoft.EntityFrameworkCore.Migrations;
-
-#nullable disable
-
-namespace FinlyticSentiment.Migrations
-{
- ///
- public partial class InitialSentimentSettings : Migration
- {
- ///
- protected override void Up(MigrationBuilder migrationBuilder)
- {
- migrationBuilder.CreateTable(
- name: "sentiment_settings",
- columns: table => new
- {
- Id = table.Column(type: "uuid", nullable: false),
- MinConfidenceScore = table.Column(type: "double precision", nullable: false),
- MaxBatchSize = table.Column(type: "integer", nullable: false),
- SweepIntervalMinutes = table.Column(type: "integer", nullable: false),
- GermanWebhookUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false),
- EnglishWebhookUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: false),
- UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false)
- },
- constraints: table =>
- {
- table.PrimaryKey("PK_sentiment_settings", x => x.Id);
- });
- }
-
- ///
- protected override void Down(MigrationBuilder migrationBuilder)
- {
- migrationBuilder.DropTable(
- name: "sentiment_settings");
- }
- }
-}
diff --git a/FinlyticSentiment/Migrations/20260813202634_CheckPendingSentiment.Designer.cs b/FinlyticSentiment/Migrations/20260813202634_CheckPendingSentiment.Designer.cs
deleted file mode 100644
index a43b810..0000000
--- a/FinlyticSentiment/Migrations/20260813202634_CheckPendingSentiment.Designer.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-//
-using System;
-using FinlyticSentiment.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 FinlyticSentiment.Migrations
-{
- [DbContext(typeof(SentimentDbContext))]
- [Migration("20260813202634_CheckPendingSentiment")]
- partial class CheckPendingSentiment
- {
- ///
- 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("FinlyticSentiment.Entities.SentimentSettingsEntity", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("uuid");
-
- b.Property("EnglishWebhookUrl")
- .IsRequired()
- .HasMaxLength(500)
- .HasColumnType("character varying(500)");
-
- b.Property("GermanWebhookUrl")
- .IsRequired()
- .HasMaxLength(500)
- .HasColumnType("character varying(500)");
-
- b.Property("MaxBatchSize")
- .HasColumnType("integer");
-
- b.Property("MinConfidenceScore")
- .HasColumnType("double precision");
-
- b.Property("SweepIntervalMinutes")
- .HasColumnType("integer");
-
- b.Property("UpdatedAt")
- .HasColumnType("timestamp with time zone");
-
- b.HasKey("Id");
-
- b.ToTable("sentiment_settings", (string)null);
- });
-#pragma warning restore 612, 618
- }
- }
-}
diff --git a/FinlyticSentiment/Migrations/20260813202634_CheckPendingSentiment.cs b/FinlyticSentiment/Migrations/20260813202634_CheckPendingSentiment.cs
deleted file mode 100644
index 3271db9..0000000
--- a/FinlyticSentiment/Migrations/20260813202634_CheckPendingSentiment.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using Microsoft.EntityFrameworkCore.Migrations;
-
-#nullable disable
-
-namespace FinlyticSentiment.Migrations
-{
- ///
- public partial class CheckPendingSentiment : Migration
- {
- ///
- protected override void Up(MigrationBuilder migrationBuilder)
- {
-
- }
-
- ///
- protected override void Down(MigrationBuilder migrationBuilder)
- {
-
- }
- }
-}
diff --git a/FinlyticSentiment/Migrations/20260815184006_AddDynamicSettings.Designer.cs b/FinlyticSentiment/Migrations/20260815184006_AddDynamicSettings.Designer.cs
deleted file mode 100644
index fb14de4..0000000
--- a/FinlyticSentiment/Migrations/20260815184006_AddDynamicSettings.Designer.cs
+++ /dev/null
@@ -1,94 +0,0 @@
-//
-using System;
-using FinlyticSentiment.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 FinlyticSentiment.Migrations
-{
- [DbContext(typeof(SentimentDbContext))]
- [Migration("20260815184006_AddDynamicSettings")]
- partial class AddDynamicSettings
- {
- ///
- 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("FinlyticCore.Entities.Settings.SettingEntity", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("uuid");
-
- b.Property("Key")
- .IsRequired()
- .HasMaxLength(150)
- .HasColumnType("character varying(150)");
-
- b.Property("LastUpdatedUtc")
- .HasColumnType("timestamp with time zone");
-
- b.Property("ServiceIdentifier")
- .IsRequired()
- .HasMaxLength(100)
- .HasColumnType("character varying(100)");
-
- b.Property("ValueJson")
- .IsRequired()
- .HasColumnType("text");
-
- b.HasKey("Id");
-
- b.HasIndex("Key")
- .IsUnique();
-
- b.ToTable("DynamicSettings");
- });
-
- modelBuilder.Entity("FinlyticSentiment.Entities.SentimentSettingsEntity", b =>
- {
- b.Property("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("uuid");
-
- b.Property("EnglishWebhookUrl")
- .IsRequired()
- .HasMaxLength(500)
- .HasColumnType("character varying(500)");
-
- b.Property("GermanWebhookUrl")
- .IsRequired()
- .HasMaxLength(500)
- .HasColumnType("character varying(500)");
-
- b.Property("MaxBatchSize")
- .HasColumnType("integer");
-
- b.Property("MinConfidenceScore")
- .HasColumnType("double precision");
-
- b.Property("SweepIntervalMinutes")
- .HasColumnType("integer");
-
- b.Property("UpdatedAt")
- .HasColumnType("timestamp with time zone");
-
- b.HasKey("Id");
-
- b.ToTable("sentiment_settings", (string)null);
- });
-#pragma warning restore 612, 618
- }
- }
-}
diff --git a/FinlyticSentiment/Migrations/20260815184006_AddDynamicSettings.cs b/FinlyticSentiment/Migrations/20260815184006_AddDynamicSettings.cs
deleted file mode 100644
index fbd967a..0000000
--- a/FinlyticSentiment/Migrations/20260815184006_AddDynamicSettings.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using System;
-using Microsoft.EntityFrameworkCore.Migrations;
-
-#nullable disable
-
-namespace FinlyticSentiment.Migrations
-{
- ///
- public partial class AddDynamicSettings : Migration
- {
- ///
- protected override void Up(MigrationBuilder migrationBuilder)
- {
- migrationBuilder.CreateTable(
- name: "DynamicSettings",
- columns: table => new
- {
- Id = table.Column(type: "uuid", nullable: false),
- Key = table.Column(type: "character varying(150)", maxLength: 150, nullable: false),
- ValueJson = table.Column(type: "text", nullable: false),
- ServiceIdentifier = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
- LastUpdatedUtc = table.Column(type: "timestamp with time zone", nullable: false)
- },
- constraints: table =>
- {
- table.PrimaryKey("PK_DynamicSettings", x => x.Id);
- });
-
- migrationBuilder.CreateIndex(
- name: "IX_DynamicSettings_Key",
- table: "DynamicSettings",
- column: "Key",
- unique: true);
- }
-
- ///
- protected override void Down(MigrationBuilder migrationBuilder)
- {
- migrationBuilder.DropTable(
- name: "DynamicSettings");
- }
- }
-}
diff --git a/FinlyticSentiment/Migrations/20260818202351_Init.Designer.cs b/FinlyticSentiment/Migrations/20260818202351_Init.Designer.cs
new file mode 100644
index 0000000..fb18a76
--- /dev/null
+++ b/FinlyticSentiment/Migrations/20260818202351_Init.Designer.cs
@@ -0,0 +1,229 @@
+//
+using System;
+using FinlyticSentiment.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 FinlyticSentiment.Migrations
+{
+ [DbContext(typeof(SentimentDbContext))]
+ [Migration("20260818202351_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("FinlyticCore.Entities.Settings.SettingEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("LastUpdatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ServiceIdentifier")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ValueJson")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Key")
+ .IsUnique();
+
+ b.ToTable("DynamicSettings");
+ });
+
+ modelBuilder.Entity("FinlyticSentiment.Entities.ArticleSentimentEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AnalyzedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ArticleId")
+ .HasColumnType("uuid");
+
+ b.Property("CompoundScore")
+ .HasColumnType("double precision");
+
+ b.Property("Confidence")
+ .HasColumnType("double precision");
+
+ b.Property("Impact")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Isin")
+ .IsRequired()
+ .HasMaxLength(12)
+ .HasColumnType("character varying(12)");
+
+ b.Property("KeyHighlight")
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)");
+
+ b.Property("Label")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NegativeProbability")
+ .HasColumnType("double precision");
+
+ b.Property("NeutralProbability")
+ .HasColumnType("double precision");
+
+ b.Property("PositiveProbability")
+ .HasColumnType("double precision");
+
+ b.Property("PublishedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Sector")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AnalyzedAtUtc");
+
+ b.HasIndex("Sector");
+
+ b.HasIndex("ArticleId", "Isin")
+ .IsUnique();
+
+ b.HasIndex("Isin", "PublishedAtUtc");
+
+ b.ToTable("article_sentiments");
+ });
+
+ modelBuilder.Entity("FinlyticSentiment.Entities.CompanySentimentSummaryEntity", b =>
+ {
+ b.Property("Isin")
+ .HasMaxLength(12)
+ .HasColumnType("character varying(12)");
+
+ b.Property("AverageConfidence")
+ .HasColumnType("double precision");
+
+ b.Property("AverageScore")
+ .HasColumnType("double precision");
+
+ b.Property("CurrentLabel")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("LastUpdatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LatestKeyHighlight")
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NegativeCount")
+ .HasColumnType("integer");
+
+ b.Property("NeutralCount")
+ .HasColumnType("integer");
+
+ b.Property("PositiveCount")
+ .HasColumnType("integer");
+
+ b.Property("Sector")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("TotalAnalysesCount")
+ .HasColumnType("integer");
+
+ b.Property("Trend")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .HasColumnType("bigint");
+
+ b.Property("WeightedScore")
+ .HasColumnType("double precision");
+
+ b.HasKey("Isin");
+
+ b.HasIndex("LastUpdatedUtc");
+
+ b.HasIndex("Sector");
+
+ b.HasIndex("WeightedScore");
+
+ b.ToTable("company_sentiment_summaries");
+ });
+
+ modelBuilder.Entity("FinlyticSentiment.Entities.SectorSentimentSummaryEntity", b =>
+ {
+ b.Property("Sector")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("AverageScore")
+ .HasColumnType("double precision");
+
+ b.Property("CurrentLabel")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("LastUpdatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TotalArticlesCount")
+ .HasColumnType("integer");
+
+ b.Property("TotalCompaniesCount")
+ .HasColumnType("integer");
+
+ b.HasKey("Sector");
+
+ b.HasIndex("LastUpdatedUtc");
+
+ b.ToTable("sector_sentiment_summaries");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/FinlyticSentiment/Migrations/20260818202351_Init.cs b/FinlyticSentiment/Migrations/20260818202351_Init.cs
new file mode 100644
index 0000000..7c13628
--- /dev/null
+++ b/FinlyticSentiment/Migrations/20260818202351_Init.cs
@@ -0,0 +1,159 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace FinlyticSentiment.Migrations
+{
+ ///
+ public partial class Init : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "article_sentiments",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ ArticleId = table.Column(type: "uuid", nullable: false),
+ Isin = table.Column(type: "character varying(12)", maxLength: 12, nullable: false),
+ Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false),
+ Sector = table.Column(type: "character varying(128)", maxLength: 128, nullable: true),
+ Label = table.Column(type: "character varying(32)", maxLength: 32, nullable: false),
+ CompoundScore = table.Column(type: "double precision", nullable: false),
+ Confidence = table.Column(type: "double precision", nullable: false),
+ Impact = table.Column(type: "character varying(32)", maxLength: 32, nullable: true),
+ PositiveProbability = table.Column(type: "double precision", nullable: false),
+ NegativeProbability = table.Column(type: "double precision", nullable: false),
+ NeutralProbability = table.Column(type: "double precision", nullable: false),
+ KeyHighlight = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true),
+ PublishedAtUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ AnalyzedAtUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_article_sentiments", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "company_sentiment_summaries",
+ columns: table => new
+ {
+ Isin = table.Column(type: "character varying(12)", maxLength: 12, nullable: false),
+ Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false),
+ Sector = table.Column(type: "character varying(128)", maxLength: 128, nullable: true),
+ CurrentLabel = table.Column(type: "character varying(32)", maxLength: 32, nullable: false),
+ AverageScore = table.Column(type: "double precision", nullable: false),
+ WeightedScore = table.Column(type: "double precision", nullable: false),
+ AverageConfidence = table.Column(type: "double precision", nullable: false),
+ TotalAnalysesCount = table.Column(type: "integer", nullable: false),
+ PositiveCount = table.Column(type: "integer", nullable: false),
+ NegativeCount = table.Column(type: "integer", nullable: false),
+ NeutralCount = table.Column(type: "integer", nullable: false),
+ LatestKeyHighlight = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true),
+ Trend = table.Column(type: "character varying(32)", maxLength: 32, nullable: true),
+ LastUpdatedUtc = table.Column(type: "timestamp with time zone", nullable: false),
+ Version = table.Column(type: "bigint", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_company_sentiment_summaries", x => x.Isin);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "DynamicSettings",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ Key = table.Column(type: "character varying(150)", maxLength: 150, nullable: false),
+ ValueJson = table.Column(type: "text", nullable: false),
+ ServiceIdentifier = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ LastUpdatedUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DynamicSettings", x => x.Id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "sector_sentiment_summaries",
+ columns: table => new
+ {
+ Sector = table.Column(type: "character varying(128)", maxLength: 128, nullable: false),
+ CurrentLabel = table.Column(type: "character varying(32)", maxLength: 32, nullable: false),
+ AverageScore = table.Column(type: "double precision", nullable: false),
+ TotalArticlesCount = table.Column(type: "integer", nullable: false),
+ TotalCompaniesCount = table.Column(type: "integer", nullable: false),
+ LastUpdatedUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_sector_sentiment_summaries", x => x.Sector);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_article_sentiments_AnalyzedAtUtc",
+ table: "article_sentiments",
+ column: "AnalyzedAtUtc");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_article_sentiments_ArticleId_Isin",
+ table: "article_sentiments",
+ columns: new[] { "ArticleId", "Isin" },
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_article_sentiments_Isin_PublishedAtUtc",
+ table: "article_sentiments",
+ columns: new[] { "Isin", "PublishedAtUtc" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_article_sentiments_Sector",
+ table: "article_sentiments",
+ column: "Sector");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_company_sentiment_summaries_LastUpdatedUtc",
+ table: "company_sentiment_summaries",
+ column: "LastUpdatedUtc");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_company_sentiment_summaries_Sector",
+ table: "company_sentiment_summaries",
+ column: "Sector");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_company_sentiment_summaries_WeightedScore",
+ table: "company_sentiment_summaries",
+ column: "WeightedScore");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DynamicSettings_Key",
+ table: "DynamicSettings",
+ column: "Key",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_sector_sentiment_summaries_LastUpdatedUtc",
+ table: "sector_sentiment_summaries",
+ column: "LastUpdatedUtc");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "article_sentiments");
+
+ migrationBuilder.DropTable(
+ name: "company_sentiment_summaries");
+
+ migrationBuilder.DropTable(
+ name: "DynamicSettings");
+
+ migrationBuilder.DropTable(
+ name: "sector_sentiment_summaries");
+ }
+ }
+}
diff --git a/FinlyticSentiment/Migrations/SentimentDbContextModelSnapshot.cs b/FinlyticSentiment/Migrations/SentimentDbContextModelSnapshot.cs
index 9f13197..3c8db27 100644
--- a/FinlyticSentiment/Migrations/SentimentDbContextModelSnapshot.cs
+++ b/FinlyticSentiment/Migrations/SentimentDbContextModelSnapshot.cs
@@ -53,37 +53,172 @@ namespace FinlyticSentiment.Migrations
b.ToTable("DynamicSettings");
});
- modelBuilder.Entity("FinlyticSentiment.Entities.SentimentSettingsEntity", b =>
+ modelBuilder.Entity("FinlyticSentiment.Entities.ArticleSentimentEntity", b =>
{
b.Property("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
- b.Property("EnglishWebhookUrl")
- .IsRequired()
- .HasMaxLength(500)
- .HasColumnType("character varying(500)");
+ b.Property("AnalyzedAtUtc")
+ .HasColumnType("timestamp with time zone");
- b.Property("GermanWebhookUrl")
- .IsRequired()
- .HasMaxLength(500)
- .HasColumnType("character varying(500)");
+ b.Property("ArticleId")
+ .HasColumnType("uuid");
- b.Property("MaxBatchSize")
- .HasColumnType("integer");
-
- b.Property("MinConfidenceScore")
+ b.Property("CompoundScore")
.HasColumnType("double precision");
- b.Property("SweepIntervalMinutes")
- .HasColumnType("integer");
+ b.Property("Confidence")
+ .HasColumnType("double precision");
- b.Property("UpdatedAt")
+ b.Property("Impact")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Isin")
+ .IsRequired()
+ .HasMaxLength(12)
+ .HasColumnType("character varying(12)");
+
+ b.Property("KeyHighlight")
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)");
+
+ b.Property("Label")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NegativeProbability")
+ .HasColumnType("double precision");
+
+ b.Property("NeutralProbability")
+ .HasColumnType("double precision");
+
+ b.Property("PositiveProbability")
+ .HasColumnType("double precision");
+
+ b.Property("PublishedAtUtc")
.HasColumnType("timestamp with time zone");
+ b.Property("Sector")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
b.HasKey("Id");
- b.ToTable("sentiment_settings", (string)null);
+ b.HasIndex("AnalyzedAtUtc");
+
+ b.HasIndex("Sector");
+
+ b.HasIndex("ArticleId", "Isin")
+ .IsUnique();
+
+ b.HasIndex("Isin", "PublishedAtUtc");
+
+ b.ToTable("article_sentiments");
+ });
+
+ modelBuilder.Entity("FinlyticSentiment.Entities.CompanySentimentSummaryEntity", b =>
+ {
+ b.Property("Isin")
+ .HasMaxLength(12)
+ .HasColumnType("character varying(12)");
+
+ b.Property("AverageConfidence")
+ .HasColumnType("double precision");
+
+ b.Property("AverageScore")
+ .HasColumnType("double precision");
+
+ b.Property("CurrentLabel")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("LastUpdatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LatestKeyHighlight")
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("NegativeCount")
+ .HasColumnType("integer");
+
+ b.Property("NeutralCount")
+ .HasColumnType("integer");
+
+ b.Property("PositiveCount")
+ .HasColumnType("integer");
+
+ b.Property("Sector")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("TotalAnalysesCount")
+ .HasColumnType("integer");
+
+ b.Property("Trend")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .HasColumnType("bigint");
+
+ b.Property("WeightedScore")
+ .HasColumnType("double precision");
+
+ b.HasKey("Isin");
+
+ b.HasIndex("LastUpdatedUtc");
+
+ b.HasIndex("Sector");
+
+ b.HasIndex("WeightedScore");
+
+ b.ToTable("company_sentiment_summaries");
+ });
+
+ modelBuilder.Entity("FinlyticSentiment.Entities.SectorSentimentSummaryEntity", b =>
+ {
+ b.Property("Sector")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("AverageScore")
+ .HasColumnType("double precision");
+
+ b.Property("CurrentLabel")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("LastUpdatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TotalArticlesCount")
+ .HasColumnType("integer");
+
+ b.Property("TotalCompaniesCount")
+ .HasColumnType("integer");
+
+ b.HasKey("Sector");
+
+ b.HasIndex("LastUpdatedUtc");
+
+ b.ToTable("sector_sentiment_summaries");
});
#pragma warning restore 612, 618
}
diff --git a/FinlyticSentiment/Program.cs b/FinlyticSentiment/Program.cs
index f4ee768..43608a8 100644
--- a/FinlyticSentiment/Program.cs
+++ b/FinlyticSentiment/Program.cs
@@ -11,7 +11,7 @@ using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args);
-// Register PostgreSQL DbContext for settings persistence
+// Register PostgreSQL DbContext for sentiment data & settings
builder.Services.AddDbContext(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddScoped(sp => sp.GetRequiredService());
@@ -23,10 +23,9 @@ builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>
// Register HttpClient
builder.Services.AddHttpClient();
-// Register Service interfaces and co-located implementations
-builder.Services.AddScoped();
+// Register Sentiment Services
+builder.Services.AddScoped();
builder.Services.AddSingleton();
-builder.Services.AddSingleton();
// Register MQTT Client (as singleton hosted service)
builder.Services.AddSingleton();
@@ -43,12 +42,14 @@ using (var scope = host.Services.CreateScope())
try
{
var db = scope.ServiceProvider.GetRequiredService();
- await db.Database.MigrateAsync();
+ var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? "";
+ await db.MigrateWithBootstrapAsync(connStr);
}
catch (Exception ex)
{
Console.WriteLine($"Critical error during database migration for FinlyticSentiment: {ex.Message}");
}
+
}
await host.RunAsync();
diff --git a/FinlyticSentiment/Project.md b/FinlyticSentiment/Project.md
deleted file mode 100644
index 0458002..0000000
--- a/FinlyticSentiment/Project.md
+++ /dev/null
@@ -1,34 +0,0 @@
-# Finlytic Sentiment Service
-
-Finlytic Sentiment is a C# microservice dedicated to real-time AI sentiment analysis of financial news. It consumes pending news articles, runs FinBERT neural model evaluations, and aggregates sentiment scores at the asset (ISIN) and sector level.
-
----
-
-## Core Features & Architecture
-
-1. **FinBERT AI Integration**:
- - Evaluates positive, negative, and neutral sentiment probabilities (`positiveProbability`, `negativeProbability`, `neutralProbability`, `compoundScore`).
-
-2. **ISIN & Sector Sentiment Aggregation**:
- - Aggregates sentiment scores per financial asset (`IsinSentimentSummaryDto`) and sector (`SectorSentimentSummaryDto`).
-
-3. **Background Worker Engine**:
- - Runs a 5-minute background loop (`SentimentBackgroundService`) polling pending articles, processing FinBERT evaluations, and publishing results over MQTT.
-
-4. **MQTT Event Channels**:
- - Publishes updates to `finlytic/sentiment/result` and responds to RPC requests on `services/request/sentiment_GetArticle/#` and `services/request/sentiment_GetIsin/#`.
-
----
-
-## Feature Status
-
-### Implemented Features
-- [x] FinBERT AI Sentiment Evaluation Service (`IFinBertAnalyzerService`).
-- [x] Sentiment Storage & In-Memory Aggregation (`ISentimentStorageService`).
-- [x] 5-minute Background Worker loop (`SentimentBackgroundService`).
-- [x] Zero-Allocation MQTT serialization via `FinlyticJsonSerializerContext`.
-- [x] Pure Worker Service architecture (`Host.CreateApplicationBuilder`, no Kestrel HTTP server).
-
-### Planned Features
-- [ ] Historical sentiment trend charting (multi-month sentiment drift per asset).
-- [ ] Financial entity sentiment impact correlation model.
diff --git a/FinlyticSentiment/Services/FinBertAnalyzerService.cs b/FinlyticSentiment/Services/FinBertAnalyzerService.cs
index 5942829..ee9e227 100644
--- a/FinlyticSentiment/Services/FinBertAnalyzerService.cs
+++ b/FinlyticSentiment/Services/FinBertAnalyzerService.cs
@@ -52,26 +52,13 @@ public class FinBertAnalyzerService : IFinBertAnalyzerService
double minConfidence = 0.60;
using (var scope = _scopeFactory.CreateScope())
{
- var settingsService = scope.ServiceProvider.GetService();
+ var settingsService = scope.ServiceProvider.GetRequiredService();
bool isEnglish = string.Equals(article.Language, "en", StringComparison.OrdinalIgnoreCase);
- if (settingsService != null)
- {
- targetUrl = isEnglish
- ? await settingsService.GetSettingAsync(SettingKeys.EnglishWebhookUrl)
- : await settingsService.GetSettingAsync(SettingKeys.GermanWebhookUrl);
- minConfidence = await settingsService.GetSettingAsync(SettingKeys.MinimumConfidenceThreshold);
- }
-
- if (string.IsNullOrWhiteSpace(targetUrl))
- {
- var settingsDb = scope.ServiceProvider.GetRequiredService();
- var settings = await settingsDb.GetSettingsAsync();
- targetUrl = isEnglish
- ? settings.EnglishWebhookUrl
- : settings.GermanWebhookUrl;
- minConfidence = settings.MinConfidenceScore;
- }
+ targetUrl = isEnglish
+ ? await settingsService.GetSettingAsync(SettingKeys.EnglishWebhookUrl)
+ : await settingsService.GetSettingAsync(SettingKeys.GermanWebhookUrl);
+ minConfidence = await settingsService.GetSettingAsync(SettingKeys.MinimumConfidenceThreshold);
}
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[FinBertAnalyzerService] Analyzing article (ID: {Id}, Lang: {Lang}) via webhook: {Url} (MinConf: {Conf})", article.Id, article.Language ?? "de", targetUrl, minConfidence);
@@ -128,11 +115,11 @@ public class FinBertAnalyzerService : IFinBertAnalyzerService
double confidence = GetDoubleProp(root, "confidence")
?? GetDoubleProp(root, "confidence_score")
?? 0.5;
- string snippet = GetStringProp(root, "summary_snippet")
+ string? impact = GetStringProp(root, "impact") ?? "MEDIUM";
+ string? keyHighlight = GetStringProp(root, "key_highlight")
+ ?? GetStringProp(root, "summary_snippet")
?? GetStringProp(root, "summary")
- ?? GetStringProp(root, "text")
- ?? article.Summary
- ?? article.Title;
+ ?? article.Summary;
double pos = 0.0, neg = 0.0, neu = 1.0;
if (root.TryGetProperty("probabilities", out var probsElem) && probsElem.ValueKind == JsonValueKind.Object)
@@ -165,44 +152,57 @@ public class FinBertAnalyzerService : IFinBertAnalyzerService
}
}
- return new FinBertResultDto
+ var result = new FinBertResultDto
{
Label = label,
CompoundScore = Math.Round(compoundScore, 4),
Confidence = Math.Round(confidence, 4),
+ Impact = impact.ToUpperInvariant(),
+ KeyHighlight = keyHighlight,
Probabilities = new FinBertProbabilities
{
Positive = Math.Round(pos, 4),
Negative = Math.Round(neg, 4),
Neutral = Math.Round(neu, 4)
- },
- SummarySnippet = snippet
+ }
};
+
+ await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[FinBertAnalyzerService] Successfully analyzed article {Id}: {Label} (Compound: {Score}, Conf: {Conf}, Impact: {Impact})", article.Id, result.Label, result.CompoundScore, result.Confidence, result.Impact);
+
+ return result;
}
}
}
-
- await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[FinBertAnalyzerService] n8n Webhook returned non-success status: {StatusCode}.", response.StatusCode);
+ else
+ {
+ await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[FinBertAnalyzerService] Webhook call failed with status: {Status} (Article ID: {Id})", response.StatusCode, article.Id);
+ }
}
catch (Exception ex)
{
- await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[FinBertAnalyzerService] Failed to call n8n sentiment webhook.");
+ await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[FinBertAnalyzerService] Exception during webhook request for article: {Id}", article.Id);
}
return null;
}
- private static string? GetStringProp(JsonElement elem, string propName)
+ private static string? GetStringProp(JsonElement element, string propName)
{
- return elem.TryGetProperty(propName, out var prop) && prop.ValueKind == JsonValueKind.String ? prop.GetString() : null;
+ if (element.TryGetProperty(propName, out var prop) && prop.ValueKind == JsonValueKind.String)
+ {
+ return prop.GetString();
+ }
+ return null;
}
- private static double? GetDoubleProp(JsonElement elem, string propName)
+ private static double? GetDoubleProp(JsonElement element, string propName)
{
- if (elem.TryGetProperty(propName, out var prop))
+ if (element.TryGetProperty(propName, out var prop))
{
- if (prop.ValueKind == JsonValueKind.Number && prop.TryGetDouble(out var d)) return d;
- if (prop.ValueKind == JsonValueKind.String && double.TryParse(prop.GetString(), out var parsed)) return parsed;
+ if (prop.ValueKind == JsonValueKind.Number && prop.TryGetDouble(out var d))
+ return d;
+ if (prop.ValueKind == JsonValueKind.String && double.TryParse(prop.GetString(), System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var parsed))
+ return parsed;
}
return null;
}
diff --git a/FinlyticSentiment/Services/SentimentBackgroundService.cs b/FinlyticSentiment/Services/SentimentBackgroundService.cs
index b6f45aa..db65c7f 100644
--- a/FinlyticSentiment/Services/SentimentBackgroundService.cs
+++ b/FinlyticSentiment/Services/SentimentBackgroundService.cs
@@ -13,13 +13,12 @@ namespace FinlyticSentiment.Services;
///
/// Background hosted worker executing periodic sentiment analysis sweeps on pending news articles
-/// and processing real-time article broadcasts.
+/// and processing real-time article broadcasts from FinlyticNews.
///
public class SentimentBackgroundService : BackgroundService
{
private readonly SentimentMqttClient _mqttClient;
private readonly IFinBertAnalyzerService _analyzer;
- private readonly ISentimentStorageService _storage;
private readonly IServiceScopeFactory _scopeFactory;
private readonly IFinlyticLogger _finlyticLogger;
@@ -28,13 +27,11 @@ public class SentimentBackgroundService : BackgroundService
public SentimentBackgroundService(
SentimentMqttClient mqttClient,
IFinBertAnalyzerService analyzer,
- ISentimentStorageService storage,
IServiceScopeFactory scopeFactory,
IFinlyticLogger finlyticLogger)
{
_mqttClient = mqttClient;
_analyzer = analyzer;
- _storage = storage;
_scopeFactory = scopeFactory;
_finlyticLogger = finlyticLogger;
}
@@ -56,11 +53,13 @@ public class SentimentBackgroundService : BackgroundService
int maxBatchSize = 10;
int sweepIntervalMinutes = 5;
- using (var scope = _scopeFactory.CreateScope())
+ try
{
+ using var scope = _scopeFactory.CreateScope();
var settings = scope.ServiceProvider.GetRequiredService();
maxBatchSize = await settings.GetSettingAsync(SettingKeys.MaxBatchSize, stoppingToken);
}
+ catch { }
var interval = TimeSpan.FromMinutes(Math.Max(1, sweepIntervalMinutes));
@@ -79,10 +78,9 @@ public class SentimentBackgroundService : BackgroundService
await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Waiting {Minutes} minute(s) until next sentiment sweep...", interval.TotalMinutes);
- using var timer = new PeriodicTimer(interval);
try
{
- await timer.WaitForNextTickAsync(stoppingToken);
+ await Task.Delay(interval, stoppingToken);
}
catch (OperationCanceledException)
{
@@ -90,7 +88,7 @@ public class SentimentBackgroundService : BackgroundService
}
}
- await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] FinlyticSentiment Background Service is shutting down gracefully.");
+ await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] FinlyticSentiment Background Service shutting down.");
}
private async Task PerformSentimentSweepAsync(int maxBatchSize, CancellationToken cancellationToken)
@@ -137,45 +135,40 @@ public class SentimentBackgroundService : BackgroundService
if (cancellationToken.IsCancellationRequested) return;
- await _storage.SaveArticleSentimentAsync(article, finbert);
+ using var scope = _scopeFactory.CreateScope();
+ var dbService = scope.ServiceProvider.GetRequiredService();
if (article.MatchedAssets != null && article.MatchedAssets.Count > 0)
{
foreach (var asset in article.MatchedAssets)
{
- if (cancellationToken.IsCancellationRequested) break;
if (string.IsNullOrWhiteSpace(asset.Isin)) continue;
- await _storage.UpdateIsinSummaryAsync(
- asset.Isin,
- asset.Name,
- "General",
- article,
- finbert);
+ await dbService.SaveArticleSentimentAsync(
+ articleId: article.Id,
+ isin: asset.Isin,
+ companyName: asset.Name,
+ sector: null,
+ publishedAt: article.PublishedAt,
+ finbert: finbert,
+ ct: cancellationToken
+ );
- await _storage.UpdateSectorSummaryAsync(
- "General",
- asset.Isin,
- article.Id.ToString(),
- finbert);
+ // Broadcast real-time updated summary for this asset
+ var summaryDto = await dbService.GetIsinSummaryDtoAsync(asset.Isin, cancellationToken);
+ if (summaryDto != null)
+ {
+ await _mqttClient.BroadcastSentimentResultAsync(asset.Isin, summaryDto);
+ }
}
}
- if (cancellationToken.IsCancellationRequested) return;
-
- bool updated = await _mqttClient.UpdateArticleStatusAsync(article.Id, "Analyzed");
- if (updated)
- {
- await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Article sentiment processed and status set to 'Analyzed' in FinlyticNews: {Title} (ID: {Id}) -> {Label}", article.Title, article.Id, finbert.Label);
- }
- else
- {
- await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Failed to confirm status update to 'Analyzed' in FinlyticNews for article: {Id}", article.Id);
- }
+ await _mqttClient.UpdateArticleStatusAsync(article.Id, "Analyzed");
+ await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentBackgroundService] Completed sentiment persistence and status transition for article {Id}.", article.Id);
}
catch (Exception ex)
{
- await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentBackgroundService] Error processing sentiment for article: {Id} ({Title})", article.Id, article.Title);
+ await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentBackgroundService] Failed to process sentiment for article: {Id}", article.Id);
}
finally
{
diff --git a/FinlyticSentiment/Services/SentimentDbService.cs b/FinlyticSentiment/Services/SentimentDbService.cs
new file mode 100644
index 0000000..3553711
--- /dev/null
+++ b/FinlyticSentiment/Services/SentimentDbService.cs
@@ -0,0 +1,556 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using FinlyticCore.Dtos.News;
+using FinlyticCore.Dtos.Sentiment;
+using FinlyticCore.Services;
+using FinlyticSentiment.Database;
+using FinlyticSentiment.Entities;
+using FinlyticSentiment.Util;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace FinlyticSentiment.Services;
+
+///
+/// Service contract for persisting and retrieving asset, article, and sector sentiments.
+///
+public interface ISentimentDbService
+{
+ Task SaveArticleSentimentAsync(
+ Guid articleId,
+ string isin,
+ string companyName,
+ string? sector,
+ DateTime publishedAt,
+ FinBertResultDto finbert,
+ CancellationToken ct = default);
+
+ Task GetCompanySentimentAsync(string isin, CancellationToken ct = default);
+
+ Task GetIsinSummaryDtoAsync(string isin, CancellationToken ct = default);
+
+ Task> GetAllCompanySentimentsAsync(int limit, int offset, string? sector = null, CancellationToken ct = default);
+
+ Task GetSectorSentimentAsync(string sector, CancellationToken ct = default);
+
+ Task> GetArticleSentimentsAsync(Guid articleId, CancellationToken ct = default);
+
+ Task GetArticleSentimentEntryAsync(Guid articleId, CancellationToken ct = default);
+
+ Task> GetSentimentTimelineAsync(string isin, int days, CancellationToken ct = default);
+}
+
+///
+/// Database persistence service implementing exponential half-life time-decay scoring,
+/// optimistic concurrency protection, and sub-millisecond pre-aggregated lookups.
+///
+public class SentimentDbService : ISentimentDbService
+{
+ private readonly SentimentDbContext _context;
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly IFinlyticLogger _finlyticLogger;
+
+ public SentimentDbService(
+ SentimentDbContext context,
+ IServiceScopeFactory scopeFactory,
+ IFinlyticLogger finlyticLogger)
+ {
+ _context = context;
+ _scopeFactory = scopeFactory;
+ _finlyticLogger = finlyticLogger;
+ }
+
+ ///
+ public async Task SaveArticleSentimentAsync(
+ Guid articleId,
+ string isin,
+ string companyName,
+ string? sector,
+ DateTime publishedAt,
+ FinBertResultDto finbert,
+ CancellationToken ct = default)
+ {
+ if (articleId == Guid.Empty || string.IsNullOrWhiteSpace(isin) || finbert == null)
+ return null;
+
+ var cleanIsin = isin.Trim().ToUpperInvariant();
+ var cleanName = !string.IsNullOrWhiteSpace(companyName) ? companyName.Trim() : cleanIsin;
+ var cleanSector = !string.IsNullOrWhiteSpace(sector) ? sector.Trim() : null;
+
+ var publishedUtc = publishedAt.Kind == DateTimeKind.Unspecified
+ ? DateTime.SpecifyKind(publishedAt, DateTimeKind.Utc)
+ : publishedAt.ToUniversalTime();
+
+ var articleSentiment = new ArticleSentimentEntity
+ {
+ Id = Guid.NewGuid(),
+ ArticleId = articleId,
+ Isin = cleanIsin,
+ Name = cleanName,
+ Sector = cleanSector,
+ Label = finbert.Label,
+ CompoundScore = finbert.CompoundScore,
+ Confidence = finbert.Confidence,
+ Impact = finbert.Impact,
+ PositiveProbability = finbert.Probabilities.Positive,
+ NegativeProbability = finbert.Probabilities.Negative,
+ NeutralProbability = finbert.Probabilities.Neutral,
+ KeyHighlight = finbert.KeyHighlight,
+ PublishedAtUtc = publishedUtc,
+ AnalyzedAtUtc = DateTime.UtcNow
+ };
+
+ // 1. Insert Article Sentiment with Idempotency Protection
+ try
+ {
+ var existing = await _context.ArticleSentiments
+ .AsNoTracking()
+ .FirstOrDefaultAsync(a => a.ArticleId == articleId && a.Isin == cleanIsin, ct);
+
+ if (existing != null)
+ {
+ await _finlyticLogger.LogDebugAsync(SettingKeys.SentimentChannel, "[SentimentDbService] Article {ArticleId} already has sentiment for ISIN {Isin}.", articleId, cleanIsin);
+ return existing;
+ }
+
+ _context.ArticleSentiments.Add(articleSentiment);
+ await _context.SaveChangesAsync(ct);
+ }
+ catch (DbUpdateException ex)
+ {
+ _context.ChangeTracker.Clear();
+ await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, "[SentimentDbService] Unique constraint or concurrency hit on insert for ISIN {Isin}. Message: {Msg}", cleanIsin, ex.Message);
+ }
+
+ // 2. Concurrency-Safe Recalculation of Company Summary with Exponential Time-Decay
+ await UpdateCompanySummaryWithRetryAsync(cleanIsin, cleanName, cleanSector, ct);
+
+ // 3. Update Sector Summary if sector is present
+ if (!string.IsNullOrWhiteSpace(cleanSector))
+ {
+ await UpdateSectorSummaryAsync(cleanSector, ct);
+ }
+
+ return articleSentiment;
+ }
+
+ private async Task UpdateCompanySummaryWithRetryAsync(
+ string isin,
+ string companyName,
+ string? sector,
+ CancellationToken ct)
+ {
+ const int maxRetries = 5;
+ for (int attempt = 1; attempt <= maxRetries; attempt++)
+ {
+ try
+ {
+ double halfLifeDays = 7.0;
+ int windowDays = 30;
+
+ try
+ {
+ using var scope = _scopeFactory.CreateScope();
+ var settings = scope.ServiceProvider.GetRequiredService();
+ halfLifeDays = await settings.GetSettingAsync(SettingKeys.TimeDecayHalfLifeDays, ct);
+ windowDays = await settings.GetSettingAsync(SettingKeys.SentimentWindowDays, ct);
+ }
+ catch { }
+
+ var cutoffDate = DateTime.UtcNow.AddDays(-windowDays);
+
+ // Query all recent sentiment analyses for this asset
+ var recentAnalyses = await _context.ArticleSentiments
+ .AsNoTracking()
+ .Where(a => a.Isin == isin && a.PublishedAtUtc >= cutoffDate)
+ .OrderByDescending(a => a.PublishedAtUtc)
+ .ToListAsync(ct);
+
+ if (recentAnalyses.Count == 0)
+ {
+ // Fallback to latest available analysis if none within window
+ var latest = await _context.ArticleSentiments
+ .AsNoTracking()
+ .Where(a => a.Isin == isin)
+ .OrderByDescending(a => a.PublishedAtUtc)
+ .Take(1)
+ .ToListAsync(ct);
+ recentAnalyses = latest;
+ }
+
+ if (recentAnalyses.Count == 0) return;
+
+ // Mathematical Half-Life Time-Decay Calculation:
+ // lambda = ln(2) / HalfLifeDays
+ // Weight_i = Confidence_i * exp(-lambda * deltaDays_i)
+ // WeightedScore = sum(Weight_i * CompoundScore_i) / sum(Weight_i)
+ double lambda = Math.Log(2.0) / Math.Max(0.1, halfLifeDays);
+ var now = DateTime.UtcNow;
+
+ double totalWeightedScore = 0.0;
+ double totalWeights = 0.0;
+ double sumRawScore = 0.0;
+ double sumConfidence = 0.0;
+ int positiveCount = 0;
+ int negativeCount = 0;
+ int neutralCount = 0;
+
+ foreach (var a in recentAnalyses)
+ {
+ double deltaDays = Math.Max(0.0, (now - a.PublishedAtUtc).TotalDays);
+ double timeDecay = Math.Exp(-lambda * deltaDays);
+ double weight = Math.Max(0.01, a.Confidence) * timeDecay;
+
+ totalWeightedScore += weight * a.CompoundScore;
+ totalWeights += weight;
+ sumRawScore += a.CompoundScore;
+ sumConfidence += a.Confidence;
+
+ if (a.CompoundScore >= 0.15 || a.Label == "POSITIVE") positiveCount++;
+ else if (a.CompoundScore <= -0.15 || a.Label == "NEGATIVE") negativeCount++;
+ else neutralCount++;
+ }
+
+ double finalWeightedScore = totalWeights > 0 ? Math.Round(totalWeightedScore / totalWeights, 4) : 0.0;
+ double finalAvgScore = Math.Round(sumRawScore / recentAnalyses.Count, 4);
+ double finalAvgConfidence = Math.Round(sumConfidence / recentAnalyses.Count, 4);
+
+ string currentLabel = finalWeightedScore switch
+ {
+ > 0.50 => "VERY_BULLISH",
+ > 0.15 => "BULLISH",
+ < -0.50 => "VERY_BEARISH",
+ < -0.15 => "BEARISH",
+ _ => "NEUTRAL"
+ };
+
+ // Trend detection: Compare recent (last 7 days) vs older weighted scores
+ string trend = "STABLE";
+ var last7DaysCutoff = now.AddDays(-7);
+ var veryRecent = recentAnalyses.Where(a => a.PublishedAtUtc >= last7DaysCutoff).ToList();
+ var older = recentAnalyses.Where(a => a.PublishedAtUtc < last7DaysCutoff).ToList();
+
+ if (veryRecent.Count > 0 && older.Count > 0)
+ {
+ double recentScore = veryRecent.Average(a => a.CompoundScore);
+ double olderScore = older.Average(a => a.CompoundScore);
+ double diff = recentScore - olderScore;
+ if (diff > 0.20) trend = "IMPROVING";
+ else if (diff < -0.20) trend = "DETERIORATING";
+ }
+
+ var latestHighlight = recentAnalyses.FirstOrDefault(a => !string.IsNullOrWhiteSpace(a.KeyHighlight))?.KeyHighlight;
+
+ var existingSummary = await _context.CompanySentiments.FirstOrDefaultAsync(c => c.Isin == isin, ct);
+ if (existingSummary == null)
+ {
+ var newSummary = new CompanySentimentSummaryEntity
+ {
+ Isin = isin,
+ Name = companyName,
+ Sector = sector,
+ CurrentLabel = currentLabel,
+ AverageScore = finalAvgScore,
+ WeightedScore = finalWeightedScore,
+ AverageConfidence = finalAvgConfidence,
+ TotalAnalysesCount = recentAnalyses.Count,
+ PositiveCount = positiveCount,
+ NegativeCount = negativeCount,
+ NeutralCount = neutralCount,
+ LatestKeyHighlight = latestHighlight,
+ Trend = trend,
+ LastUpdatedUtc = DateTime.UtcNow,
+ Version = 1
+ };
+ _context.CompanySentiments.Add(newSummary);
+ }
+ else
+ {
+ existingSummary.Name = !string.IsNullOrWhiteSpace(companyName) ? companyName : existingSummary.Name;
+ existingSummary.Sector = sector ?? existingSummary.Sector;
+ existingSummary.CurrentLabel = currentLabel;
+ existingSummary.AverageScore = finalAvgScore;
+ existingSummary.WeightedScore = finalWeightedScore;
+ existingSummary.AverageConfidence = finalAvgConfidence;
+ existingSummary.TotalAnalysesCount = recentAnalyses.Count;
+ existingSummary.PositiveCount = positiveCount;
+ existingSummary.NegativeCount = negativeCount;
+ existingSummary.NeutralCount = neutralCount;
+ existingSummary.LatestKeyHighlight = latestHighlight ?? existingSummary.LatestKeyHighlight;
+ existingSummary.Trend = trend;
+ existingSummary.LastUpdatedUtc = DateTime.UtcNow;
+ existingSummary.Version++;
+ }
+
+ await _context.SaveChangesAsync(ct);
+ await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentDbService] Updated company summary for {Isin} ({Name}): {Label} (Weighted: {Score}, Trend: {Trend})", isin, companyName, currentLabel, finalWeightedScore, trend);
+ return;
+ }
+ catch (DbUpdateConcurrencyException)
+ {
+ _context.ChangeTracker.Clear();
+ if (attempt == maxRetries) throw;
+ await Task.Delay(Random.Shared.Next(50, 150) * attempt, ct);
+ }
+ catch (Exception ex)
+ {
+ _context.ChangeTracker.Clear();
+ await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentDbService] Error updating company summary for {Isin} (Attempt {Attempt})", isin, attempt);
+ if (attempt == maxRetries) throw;
+ await Task.Delay(100, ct);
+ }
+ }
+ }
+
+ private async Task UpdateSectorSummaryAsync(string sector, CancellationToken ct)
+ {
+ try
+ {
+ var companySummaries = await _context.CompanySentiments
+ .AsNoTracking()
+ .Where(c => c.Sector == sector)
+ .ToListAsync(ct);
+
+ if (companySummaries.Count == 0) return;
+
+ double avgScore = Math.Round(companySummaries.Average(c => c.WeightedScore), 4);
+ int totalArticles = companySummaries.Sum(c => c.TotalAnalysesCount);
+ int totalCompanies = companySummaries.Count;
+
+ string currentLabel = avgScore switch
+ {
+ > 0.15 => "POSITIVE",
+ < -0.15 => "NEGATIVE",
+ _ => "NEUTRAL"
+ };
+
+ var existing = await _context.SectorSentiments.FirstOrDefaultAsync(s => s.Sector == sector, ct);
+ if (existing == null)
+ {
+ _context.SectorSentiments.Add(new SectorSentimentSummaryEntity
+ {
+ Sector = sector,
+ CurrentLabel = currentLabel,
+ AverageScore = avgScore,
+ TotalArticlesCount = totalArticles,
+ TotalCompaniesCount = totalCompanies,
+ LastUpdatedUtc = DateTime.UtcNow
+ });
+ }
+ else
+ {
+ existing.CurrentLabel = currentLabel;
+ existing.AverageScore = avgScore;
+ existing.TotalArticlesCount = totalArticles;
+ existing.TotalCompaniesCount = totalCompanies;
+ existing.LastUpdatedUtc = DateTime.UtcNow;
+ }
+
+ await _context.SaveChangesAsync(ct);
+ }
+ catch (Exception ex)
+ {
+ _context.ChangeTracker.Clear();
+ await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, ex, "[SentimentDbService] Failed to update sector summary for {Sector}", sector);
+ }
+ }
+
+ ///
+ public async Task GetCompanySentimentAsync(string isin, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(isin)) return null;
+ var cleanIsin = isin.Trim().ToUpperInvariant();
+ return await _context.CompanySentiments
+ .AsNoTracking()
+ .FirstOrDefaultAsync(c => c.Isin == cleanIsin, ct);
+ }
+
+ ///
+ public async Task GetIsinSummaryDtoAsync(string isin, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(isin)) return null;
+ var cleanIsin = isin.Trim().ToUpperInvariant();
+
+ var summary = await _context.CompanySentiments
+ .AsNoTracking()
+ .FirstOrDefaultAsync(c => c.Isin == cleanIsin, ct);
+
+ if (summary == null) return null;
+
+ var recentAnalyses = await _context.ArticleSentiments
+ .AsNoTracking()
+ .Where(a => a.Isin == cleanIsin)
+ .OrderByDescending(a => a.PublishedAtUtc)
+ .Take(15)
+ .ToListAsync(ct);
+
+ return new IsinSentimentSummaryDto
+ {
+ Isin = summary.Isin,
+ CompanyName = summary.Name,
+ Sector = summary.Sector ?? "General",
+ LastUpdated = summary.LastUpdatedUtc.ToString("o"),
+ CurrentSummary = new IsinCurrentSummary
+ {
+ CompoundScore = summary.WeightedScore,
+ SentimentLabel = summary.CurrentLabel,
+ AvgConfidence = summary.AverageConfidence,
+ TotalArticlesAnalyzed = summary.TotalAnalysesCount,
+ PositiveArticles = summary.PositiveCount,
+ NegativeArticles = summary.NegativeCount,
+ NeutralArticles = summary.NeutralCount,
+ Trend = summary.Trend,
+ KeyHighlight = summary.LatestKeyHighlight
+ },
+ Analyses = recentAnalyses.Select(a => new IsinAnalysisEntry
+ {
+ AnalysisId = a.Id.ToString(),
+ Timestamp = a.AnalyzedAtUtc.ToString("o"),
+ Article = new IsinAnalysisArticleRef
+ {
+ ArticleId = a.ArticleId.ToString(),
+ Title = a.Name,
+ Source = "FinlyticNews",
+ PublishedAt = a.PublishedAtUtc.ToString("o")
+ },
+ FinbertResult = new FinBertResultDto
+ {
+ Label = a.Label,
+ CompoundScore = a.CompoundScore,
+ Confidence = a.Confidence,
+ Impact = a.Impact,
+ KeyHighlight = a.KeyHighlight,
+ Probabilities = new FinBertProbabilities
+ {
+ Positive = a.PositiveProbability,
+ Negative = a.NegativeProbability,
+ Neutral = a.NeutralProbability
+ }
+ },
+ SummarySnippet = a.KeyHighlight ?? string.Empty
+ }).ToList()
+ };
+ }
+
+ ///
+ public async Task> GetAllCompanySentimentsAsync(
+ int limit,
+ int offset,
+ string? sector = null,
+ CancellationToken ct = default)
+ {
+ var query = _context.CompanySentiments.AsNoTracking().AsQueryable();
+
+ if (!string.IsNullOrWhiteSpace(sector))
+ {
+ var cleanSector = sector.Trim();
+ query = query.Where(c => c.Sector == cleanSector);
+ }
+
+ return await query
+ .OrderByDescending(c => c.LastUpdatedUtc)
+ .Skip(Math.Max(0, offset))
+ .Take(limit > 0 ? Math.Min(limit, 100) : 50)
+ .ToListAsync(ct);
+ }
+
+ ///
+ public async Task GetSectorSentimentAsync(string sector, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(sector)) return null;
+ var cleanSector = sector.Trim();
+
+ var entity = await _context.SectorSentiments
+ .AsNoTracking()
+ .FirstOrDefaultAsync(s => s.Sector == cleanSector, ct);
+
+ if (entity == null) return null;
+
+ var activeIsins = await _context.CompanySentiments
+ .AsNoTracking()
+ .Where(c => c.Sector == cleanSector)
+ .Select(c => c.Isin)
+ .Take(20)
+ .ToListAsync(ct);
+
+ return new SectorSentimentSummaryDto
+ {
+ Sector = entity.Sector,
+ LastUpdated = entity.LastUpdatedUtc.ToString("o"),
+ CurrentSummary = new SectorCurrentSummary
+ {
+ CompoundScore = entity.AverageScore,
+ SentimentLabel = entity.CurrentLabel,
+ ActiveIsins = activeIsins,
+ TotalArticlesAnalyzed = entity.TotalArticlesCount
+ }
+ };
+ }
+
+ ///
+ public async Task> GetArticleSentimentsAsync(Guid articleId, CancellationToken ct = default)
+ {
+ if (articleId == Guid.Empty) return [];
+ return await _context.ArticleSentiments
+ .AsNoTracking()
+ .Where(a => a.ArticleId == articleId)
+ .ToListAsync(ct);
+ }
+
+ ///
+ public async Task GetArticleSentimentEntryAsync(Guid articleId, CancellationToken ct = default)
+ {
+ if (articleId == Guid.Empty) return null;
+
+ var a = await _context.ArticleSentiments
+ .AsNoTracking()
+ .FirstOrDefaultAsync(x => x.ArticleId == articleId, ct);
+
+ if (a == null) return null;
+
+ return new IsinAnalysisEntry
+ {
+ AnalysisId = a.Id.ToString(),
+ Timestamp = a.AnalyzedAtUtc.ToString("o"),
+ Article = new IsinAnalysisArticleRef
+ {
+ ArticleId = a.ArticleId.ToString(),
+ Title = a.Name,
+ Source = "FinlyticNews",
+ PublishedAt = a.PublishedAtUtc.ToString("o")
+ },
+ FinbertResult = new FinBertResultDto
+ {
+ Label = a.Label,
+ CompoundScore = a.CompoundScore,
+ Confidence = a.Confidence,
+ Impact = a.Impact,
+ KeyHighlight = a.KeyHighlight,
+ Probabilities = new FinBertProbabilities
+ {
+ Positive = a.PositiveProbability,
+ Negative = a.NegativeProbability,
+ Neutral = a.NeutralProbability
+ }
+ },
+ SummarySnippet = a.KeyHighlight ?? string.Empty
+ };
+ }
+
+ ///
+ public async Task> GetSentimentTimelineAsync(string isin, int days, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(isin)) return [];
+ var cleanIsin = isin.Trim().ToUpperInvariant();
+ var cutoff = DateTime.UtcNow.AddDays(-Math.Max(1, days));
+
+ return await _context.ArticleSentiments
+ .AsNoTracking()
+ .Where(a => a.Isin == cleanIsin && a.PublishedAtUtc >= cutoff)
+ .OrderByDescending(a => a.PublishedAtUtc)
+ .ToListAsync(ct);
+ }
+}
diff --git a/FinlyticSentiment/Services/SentimentStorageService.cs b/FinlyticSentiment/Services/SentimentStorageService.cs
deleted file mode 100644
index 3466a29..0000000
--- a/FinlyticSentiment/Services/SentimentStorageService.cs
+++ /dev/null
@@ -1,345 +0,0 @@
-using System;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Text.Encodings.Web;
-using System.Text.Json;
-using System.Threading;
-using System.Threading.Tasks;
-using FinlyticCore.Dtos.News;
-using FinlyticCore.Dtos.Sentiment;
-using FinlyticCore.Services;
-using FinlyticSentiment.Util;
-using Microsoft.Extensions.Configuration;
-
-namespace FinlyticSentiment.Services;
-
-///
-/// Defines the persistence contract for maintaining two-stage ISIN and Sector JSON sentiment summaries in the file system.
-///
-public interface ISentimentStorageService
-{
- Task UpdateIsinSummaryAsync(string isin, string companyName, string sector, NewsArticleDto article, FinBertResultDto finbert);
- Task UpdateSectorSummaryAsync(string sector, string isin, string articleId, FinBertResultDto finbert);
- Task SaveArticleSentimentAsync(NewsArticleDto article, FinBertResultDto finbert);
- Task GetArticleSentimentAsync(string articleId);
- Task GetIsinSummaryAsync(string isin);
-}
-
-public class SentimentStorageService : ISentimentStorageService
-{
- private static readonly ConcurrentDictionary FileLocks = new();
-
- private readonly IFinlyticLogger _finlyticLogger;
- private readonly string _basePath;
- private readonly JsonSerializerOptions _jsonOptions;
-
- public SentimentStorageService(IConfiguration configuration, IFinlyticLogger finlyticLogger)
- {
- _finlyticLogger = finlyticLogger;
- _basePath = configuration["Storage:SummariesPath"] ?? "data/summaries";
-
- Directory.CreateDirectory(Path.Combine(_basePath, "isin"));
- Directory.CreateDirectory(Path.Combine(_basePath, "sectors"));
- Directory.CreateDirectory(Path.Combine(_basePath, "articles"));
-
- _jsonOptions = new JsonSerializerOptions
- {
- WriteIndented = true,
- Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
- };
- }
-
- ///
- public async Task SaveArticleSentimentAsync(NewsArticleDto article, FinBertResultDto finbert)
- {
- if (article == null || article.Id == Guid.Empty) return;
-
- string cleanId = article.Id.ToString();
- string filePath = Path.Combine(_basePath, "articles", $"{cleanId}.json");
-
- var fileLock = FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1));
- await fileLock.WaitAsync();
-
- try
- {
- string nowIso = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ");
- var entry = new IsinAnalysisEntry
- {
- AnalysisId = $"sent_{Guid.NewGuid():N}",
- Timestamp = nowIso,
- Article = new IsinAnalysisArticleRef
- {
- ArticleId = cleanId,
- Title = article.Title,
- PublishedAt = article.PublishedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"),
- Source = article.Author ?? "FinlyticNews"
- },
- FinbertResult = finbert,
- SummarySnippet = finbert.SummarySnippet ?? article.Summary ?? string.Empty
- };
-
- var json = JsonSerializer.Serialize(entry, _jsonOptions);
- await File.WriteAllTextAsync(filePath, json);
- await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentStorageService] Successfully saved article sentiment file: {Path}", filePath);
- }
- catch (Exception ex)
- {
- await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentStorageService] Failed to write article sentiment file: {Path}", filePath);
- }
- finally
- {
- fileLock.Release();
- }
- }
-
- ///
- public async Task GetArticleSentimentAsync(string articleId)
- {
- if (string.IsNullOrWhiteSpace(articleId)) return null;
-
- string cleanId = articleId.Trim();
- string articleFilePath = Path.Combine(_basePath, "articles", $"{cleanId}.json");
-
- if (File.Exists(articleFilePath))
- {
- var fileLock = FileLocks.GetOrAdd(articleFilePath, _ => new SemaphoreSlim(1, 1));
- await fileLock.WaitAsync();
- try
- {
- var json = await File.ReadAllTextAsync(articleFilePath);
- return JsonSerializer.Deserialize(json, _jsonOptions);
- }
- catch (Exception ex)
- {
- await _finlyticLogger.LogWarningAsync(SettingKeys.SentimentChannel, ex, "[SentimentStorageService] Failed to read article sentiment file: {Path}", articleFilePath);
- }
- finally
- {
- fileLock.Release();
- }
- }
-
- return null;
- }
-
- ///
- public async Task GetIsinSummaryAsync(string isin)
- {
- if (string.IsNullOrWhiteSpace(isin)) return null;
-
- string cleanIsin = isin.Trim().ToUpperInvariant();
- string filePath = Path.Combine(_basePath, "isin", $"{cleanIsin}.json");
-
- if (!File.Exists(filePath)) return null;
-
- var fileLock = FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1));
- await fileLock.WaitAsync();
-
- try
- {
- var json = await File.ReadAllTextAsync(filePath);
- return JsonSerializer.Deserialize(json, _jsonOptions);
- }
- catch (Exception ex)
- {
- await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentStorageService] Error reading ISIN summary file: {Path}", filePath);
- return null;
- }
- finally
- {
- fileLock.Release();
- }
- }
-
- ///
- public async Task UpdateIsinSummaryAsync(string isin, string companyName, string sector, NewsArticleDto article, FinBertResultDto finbert)
- {
- if (string.IsNullOrWhiteSpace(isin)) return;
-
- string cleanIsin = isin.Trim().ToUpperInvariant();
- string filePath = Path.Combine(_basePath, "isin", $"{cleanIsin}.json");
-
- var fileLock = FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1));
- await fileLock.WaitAsync();
-
- try
- {
- var analyses = new List();
- if (File.Exists(filePath))
- {
- var existingJson = await File.ReadAllTextAsync(filePath);
- var existing = JsonSerializer.Deserialize(existingJson, _jsonOptions);
- if (existing?.Analyses != null)
- {
- analyses.AddRange(existing.Analyses);
- }
- }
-
- string cleanArticleId = article.Id.ToString();
- analyses.RemoveAll(a => string.Equals(a.Article?.ArticleId, cleanArticleId, StringComparison.OrdinalIgnoreCase));
-
- string nowIso = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ");
- var newEntry = new IsinAnalysisEntry
- {
- AnalysisId = $"sent_{Guid.NewGuid():N}",
- Timestamp = nowIso,
- Article = new IsinAnalysisArticleRef
- {
- ArticleId = cleanArticleId,
- Title = article.Title,
- PublishedAt = article.PublishedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"),
- Source = article.Author ?? "FinlyticNews"
- },
- FinbertResult = finbert,
- SummarySnippet = finbert.SummarySnippet ?? article.Summary ?? string.Empty
- };
-
- analyses.Add(newEntry);
-
- var cutoff = DateTime.UtcNow.AddDays(-14);
- var validAnalyses = analyses.Where(a =>
- {
- if (DateTime.TryParse(a.Article?.PublishedAt ?? a.Timestamp, out var pubDate))
- {
- return pubDate >= cutoff;
- }
- return true;
- }).ToList();
-
- var updatedAnalyses = validAnalyses.OrderByDescending(a => a.Article?.PublishedAt ?? a.Timestamp).Take(50).ToList();
-
- double totalCompound = 0.0;
- double totalConf = 0.0;
-
- foreach (var item in updatedAnalyses)
- {
- if (item.FinbertResult == null) continue;
- totalCompound += item.FinbertResult.CompoundScore;
- totalConf += item.FinbertResult.Confidence;
- }
-
- int total = updatedAnalyses.Count;
- double avgCompound = total > 0 ? totalCompound / total : 0.0;
- double avgConf = total > 0 ? totalConf / total : 0.0;
-
- string overallLabel = "NEUTRAL";
- if (avgCompound >= 0.15) overallLabel = "POSITIVE";
- else if (avgCompound <= -0.15) overallLabel = "NEGATIVE";
-
- var summary = new IsinSentimentSummaryDto
- {
- Isin = cleanIsin,
- CompanyName = companyName,
- Sector = sector,
- LastUpdated = nowIso,
- CurrentSummary = new IsinCurrentSummary
- {
- CompoundScore = Math.Round(avgCompound, 4),
- SentimentLabel = overallLabel,
- AvgConfidence = Math.Round(avgConf, 4),
- TotalArticlesAnalyzed = total,
- Text = $"Synthesized sentiment across {total} articles is {overallLabel}."
- },
- Analyses = updatedAnalyses
- };
-
- var outJson = JsonSerializer.Serialize(summary, _jsonOptions);
- await File.WriteAllTextAsync(filePath, outJson);
- await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentStorageService] Updated ISIN summary file: {Path} (Total: {Count}, Score: {Score:F2})", filePath, updatedAnalyses.Count, avgCompound);
- }
- catch (Exception ex)
- {
- await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentStorageService] Failed to update ISIN summary for {Isin}", cleanIsin);
- }
- finally
- {
- fileLock.Release();
- }
- }
-
- ///
- public async Task UpdateSectorSummaryAsync(string sector, string isin, string articleId, FinBertResultDto finbert)
- {
- if (string.IsNullOrWhiteSpace(sector)) return;
-
- string cleanSector = sector.Trim().ToLowerInvariant();
- string filePath = Path.Combine(_basePath, "sectors", $"{cleanSector}.json");
-
- var fileLock = FileLocks.GetOrAdd(filePath, _ => new SemaphoreSlim(1, 1));
- await fileLock.WaitAsync();
-
- try
- {
- var analyses = new List();
- if (File.Exists(filePath))
- {
- var existingJson = await File.ReadAllTextAsync(filePath);
- var existing = JsonSerializer.Deserialize(existingJson, _jsonOptions);
- if (existing?.Analyses != null)
- {
- analyses.AddRange(existing.Analyses);
- }
- }
-
- string cleanIsin = isin.Trim().ToUpperInvariant();
- string nowIso = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ");
-
- analyses.RemoveAll(a => string.Equals(a.ArticleId, articleId, StringComparison.OrdinalIgnoreCase) && string.Equals(a.RelatedIsin, cleanIsin, StringComparison.OrdinalIgnoreCase));
-
- analyses.Add(new SectorAnalysisEntry
- {
- AnalysisId = $"sec_{Guid.NewGuid():N}",
- Timestamp = nowIso,
- RelatedIsin = cleanIsin,
- ArticleId = articleId,
- FinbertResult = finbert
- });
-
- var cutoff = DateTime.UtcNow.AddDays(-14);
- var updatedAnalyses = analyses.Where(a =>
- {
- if (DateTime.TryParse(a.Timestamp, out var ts))
- {
- return ts >= cutoff;
- }
- return true;
- }).OrderByDescending(a => a.Timestamp).Take(100).ToList();
-
- var activeIsins = updatedAnalyses.Select(a => a.RelatedIsin).Where(i => !string.IsNullOrEmpty(i)).Distinct().ToList();
- double totalSectorCompound = updatedAnalyses.Sum(s => s.FinbertResult.CompoundScore);
- double avgSectorCompound = updatedAnalyses.Count > 0 ? totalSectorCompound / updatedAnalyses.Count : 0.0;
-
- string sectorLabel = "NEUTRAL";
- if (avgSectorCompound >= 0.15) sectorLabel = "POSITIVE";
- else if (avgSectorCompound <= -0.15) sectorLabel = "NEGATIVE";
-
- var summary = new SectorSentimentSummaryDto
- {
- Sector = sector,
- LastUpdated = nowIso,
- CurrentSummary = new SectorCurrentSummary
- {
- CompoundScore = Math.Round(avgSectorCompound, 4),
- SentimentLabel = sectorLabel,
- ActiveIsins = activeIsins,
- Text = $"Sector {sector} aggregate sentiment: {sectorLabel}."
- },
- Analyses = updatedAnalyses
- };
-
- var outJson = JsonSerializer.Serialize(summary, _jsonOptions);
- await File.WriteAllTextAsync(filePath, outJson);
- await _finlyticLogger.LogInfoAsync(SettingKeys.SentimentChannel, "[SentimentStorageService] Updated Sector summary file: {Path} (Active ISINs: {Count})", filePath, activeIsins.Count);
- }
- catch (Exception ex)
- {
- await _finlyticLogger.LogErrorAsync(SettingKeys.SentimentChannel, ex, "[SentimentStorageService] Failed to update Sector summary for {Sector}", sector);
- }
- finally
- {
- fileLock.Release();
- }
- }
-}
\ No newline at end of file
diff --git a/FinlyticSentiment/Services/SettingsDbService.cs b/FinlyticSentiment/Services/SettingsDbService.cs
deleted file mode 100644
index 8d99d9d..0000000
--- a/FinlyticSentiment/Services/SettingsDbService.cs
+++ /dev/null
@@ -1,115 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Threading.Tasks;
-using FinlyticSentiment.Database;
-using FinlyticSentiment.Entities;
-using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Logging;
-
-namespace FinlyticSentiment.Services;
-
-///
-/// Interface for reading and persisting FinlyticSentiment runtime configuration settings in PostgreSQL.
-///
-public interface ISettingsDbService
-{
- ///
- /// Retrieves current sentiment settings from PostgreSQL database, seeding defaults if empty.
- ///
- Task GetSettingsAsync();
-
- ///
- /// Persists updated settings entity to PostgreSQL.
- ///
- Task SaveSettingsAsync(SentimentSettingsEntity settings);
-
- ///
- /// Updates settings from a key-value dictionary received via Admin Panel MQTT events.
- ///
- Task UpdateSettingsFromDictionaryAsync(Dictionary dictionary);
-}
-
-///
-/// EF Core PostgreSQL implementation of .
-///
-public class SettingsDbService : ISettingsDbService
-{
- private readonly SentimentDbContext _context;
- private readonly ILogger _logger;
-
- ///
- /// Initializes a new instance of the class.
- ///
- public SettingsDbService(SentimentDbContext context, ILogger logger)
- {
- _context = context;
- _logger = logger;
- }
-
- ///
- /// Retrieves current sentiment settings from PostgreSQL database.
- ///
- public async Task GetSettingsAsync()
- {
- var settings = await _context.Settings.AsNoTracking().FirstOrDefaultAsync();
- if (settings == null)
- {
- settings = new SentimentSettingsEntity { Id = Guid.NewGuid() };
- _context.Settings.Add(settings);
- await _context.SaveChangesAsync();
- _context.ChangeTracker.Clear();
- }
- return settings;
- }
-
- ///
- /// Persists updated settings entity to PostgreSQL.
- ///
- public async Task SaveSettingsAsync(SentimentSettingsEntity 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.MinConfidenceScore = settings.MinConfidenceScore;
- existing.MaxBatchSize = settings.MaxBatchSize;
- existing.SweepIntervalMinutes = settings.SweepIntervalMinutes;
- existing.GermanWebhookUrl = settings.GermanWebhookUrl;
- existing.EnglishWebhookUrl = settings.EnglishWebhookUrl;
- existing.UpdatedAt = settings.UpdatedAt;
- _context.Settings.Update(existing);
- }
- await _context.SaveChangesAsync();
- return settings;
- }
-
- ///
- /// Updates settings from a key-value dictionary.
- ///
- public async Task UpdateSettingsFromDictionaryAsync(Dictionary dictionary)
- {
- var settings = await GetSettingsAsync();
-
- foreach (var (key, value) in dictionary)
- {
- if (string.Equals(key, "MinConfidenceScore", StringComparison.OrdinalIgnoreCase) && double.TryParse(value, out var mcs))
- settings.MinConfidenceScore = mcs;
- else if (string.Equals(key, "MaxBatchSize", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var mbs))
- settings.MaxBatchSize = mbs;
- else if (string.Equals(key, "SweepIntervalMinutes", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var sim))
- settings.SweepIntervalMinutes = sim;
- else if (string.Equals(key, "GermanWebhookUrl", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(value))
- settings.GermanWebhookUrl = value.Trim();
- else if (string.Equals(key, "EnglishWebhookUrl", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(value))
- settings.EnglishWebhookUrl = value.Trim();
- }
-
- settings.UpdatedAt = DateTime.UtcNow;
- await SaveSettingsAsync(settings);
- _logger.LogInformation("[{Channel}] Successfully updated {Count} sentiment settings in PostgreSQL database.", "SentimentChannel", dictionary.Count);
- }
-}
diff --git a/FinlyticSentiment/Util/SentimentMqttClient.cs b/FinlyticSentiment/Util/SentimentMqttClient.cs
index 091a54a..ca6b13a 100644
--- a/FinlyticSentiment/Util/SentimentMqttClient.cs
+++ b/FinlyticSentiment/Util/SentimentMqttClient.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -10,8 +11,8 @@ using FinlyticCore.Dtos.Settings;
using FinlyticCore.Models;
using FinlyticCore.Services;
using FinlyticCore.Util;
+using FinlyticSentiment.Entities;
using FinlyticSentiment.Services;
-using FinlyticSentiment.Util;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@@ -20,7 +21,7 @@ using Microsoft.Extensions.Logging;
namespace FinlyticSentiment.Util;
///
-/// Managed MQTT client for requesting pending news articles and updating sentiment results.
+/// Managed MQTT client for sentiment evaluations and RPC queries.
///
public class SentimentMqttClient : ManagedMqttClient, IHostedService
{
@@ -43,12 +44,7 @@ public class SentimentMqttClient : ManagedMqttClient, IHostedService
///
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"),
- ClientId = $"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "FinlyticSentiment")}_{Guid.NewGuid()}"
- };
+ var config = MqttConfiguration.FromConfiguration(_configuration, "FinlyticSentiment");
_logger.LogInformation("Starting Sentiment MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
await ConnectAsync(config);
@@ -71,403 +67,226 @@ public class SentimentMqttClient : ManagedMqttClient, IHostedService
///
protected override async Task OnConnectedAsync()
{
- _logger.LogInformation("Sentiment MQTT client connected. Subscribing to RPC response and broadcast topics...");
- await SubscribeAsync("services/response/#");
- await SubscribeAsync("services/news/completed");
- await SubscribeAsync("services/request/sentiment_GetArticle/#");
- await SubscribeAsync("services/request/sentiment_GetIsin/#");
- await SubscribeAsync("services/request/sentiment_Analyze/#");
- await SubscribeAsync("services/request/sentiment_settings_GetAll/#");
- await SubscribeAsync("services/request/sentiment_settings_Update/#");
- await SubscribeAsync("services/request/health_Ping/#");
- await SubscribeAsync("services/config/updated/#");
+ _logger.LogInformation("Sentiment MQTT client connected. Registering RPC topic subscriptions...");
+
+ await SubscribeAsync(MqttTopics.ResponseWildcard);
+ await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.SentimentGetIsin), HandleSentimentGetIsinRpcAsync);
+ await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.SentimentGetSector), HandleSentimentGetSectorRpcAsync);
+ await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.SentimentGetArticle), HandleSentimentGetArticleRpcAsync);
+ await SubscribeRpcAsync>(MqttTopics.RequestFilter(MqttTopics.Channels.SentimentGetAll), HandleSentimentGetAllRpcAsync);
+ await SubscribeRpcAsync(MqttTopics.RequestFilter(MqttTopics.Channels.SentimentAnalyze), HandleSentimentAnalyzeRpcAsync);
+ await SubscribeRpcAsync