diff --git a/Finlytic.sln b/Finlytic.sln
index fd3b9a7..d937376 100644
--- a/Finlytic.sln
+++ b/Finlytic.sln
@@ -29,6 +29,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticEngine.Tests", "Fin
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticBot.Tests", "FinlyticBot.Tests\FinlyticBot.Tests.csproj", "{1E282E4D-C63E-49E6-879D-DDEEDA530E47}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticNotify", "FinlyticNotify\FinlyticNotify.csproj", "{B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticNotify.Tests", "FinlyticNotify.Tests\FinlyticNotify.Tests.csproj", "{6E54FE48-A814-469C-B2E4-67C0EB575A9E}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -175,6 +179,30 @@ Global
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x64.Build.0 = Release|Any CPU
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x86.ActiveCfg = Release|Any CPU
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x86.Build.0 = Release|Any CPU
+ {B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|x64.Build.0 = Debug|Any CPU
+ {B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Debug|x86.Build.0 = Debug|Any CPU
+ {B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|x64.ActiveCfg = Release|Any CPU
+ {B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|x64.Build.0 = Release|Any CPU
+ {B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|x86.ActiveCfg = Release|Any CPU
+ {B8A8F8CF-1FBF-4578-A75B-14AE76146B2A}.Release|x86.Build.0 = Release|Any CPU
+ {6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|x64.Build.0 = Debug|Any CPU
+ {6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Debug|x86.Build.0 = Debug|Any CPU
+ {6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|x64.ActiveCfg = Release|Any CPU
+ {6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|x64.Build.0 = Release|Any CPU
+ {6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|x86.ActiveCfg = Release|Any CPU
+ {6E54FE48-A814-469C-B2E4-67C0EB575A9E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/FinlyticNotify.Tests/FinlyticNotify.Tests.csproj b/FinlyticNotify.Tests/FinlyticNotify.Tests.csproj
new file mode 100644
index 0000000..29fc3b7
--- /dev/null
+++ b/FinlyticNotify.Tests/FinlyticNotify.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FinlyticNotify.Tests/Services/NotificationFormatterTests.cs b/FinlyticNotify.Tests/Services/NotificationFormatterTests.cs
new file mode 100644
index 0000000..e358750
--- /dev/null
+++ b/FinlyticNotify.Tests/Services/NotificationFormatterTests.cs
@@ -0,0 +1,312 @@
+using System;
+using System.Collections.Generic;
+using FinlyticCore.Dtos.Bot;
+using FinlyticCore.Dtos.News;
+using FinlyticCore.Dtos.TechnicalAnalysis;
+using FinlyticCore.Dtos.Trading;
+using FinlyticNotify.Services;
+using Xunit;
+
+namespace FinlyticNotify.Tests.Services;
+
+public class NotificationFormatterTests
+{
+ private readonly NotificationFormatter _formatter = new();
+
+ [Fact]
+ public void FormatProposalNotification_LongBuy_FormatsCorrectly()
+ {
+ // Arrange
+ var proposal = new TradeProposalDto(
+ ProposalId: Guid.NewGuid(),
+ UnderlyingIsin: "US67066G1040",
+ Symbol: "NVDA",
+ StrategyKey: "TrendPullbackFvg",
+ Direction: SignalDirection.Buy,
+ QualityScore: 88m,
+ CompositeScore: 84.5m,
+ CurrentPrice: 125.50m,
+ EntryPrice: 125.00m,
+ InvalidationPrice: 120.00m,
+ ExitPlan: new ExitPlan(
+ StrategyType: ExitStrategyType.FixedSingleTarget,
+ InitialStopLoss: 120.00m,
+ TakeProfitStages:
+ [
+ new TakeProfitStage(1, 135.00m, 1.0m, 2.0m, "TP1: 100% Exit at 135.00")
+ ]
+ ),
+ SelectedDerivative: new DerivativeSelectionDto(
+ DerivativeIsin: "DE000TEST123",
+ DerivativeWkn: null,
+ Issuer: "HSBC",
+ OptionType: "LONG",
+ Strike: 110.0m,
+ Barrier: 110.0m,
+ Leverage: 8.2m,
+ SafetyBufferPercent: 0.08m,
+ SpreadPercentage: 0.005m,
+ Size: 100m
+ ),
+ AiValidation: new AiValidationResultDto(
+ IsApproved: true,
+ Confidence: 0.85m,
+ Source: ValidationSource.Ai,
+ ThesisSummary: "Bruch des lokalen Abwärtstrends mit starkem Volumen und positiver Halbleiter-Sektor-Dynamik.",
+ InvalidationReason: "",
+ KeyCatalysts: ["Earnings Momentum"],
+ IdentifiedRisks: ["Allgemeine Marktvolatilität"]
+ ),
+ CreatedAtUtc: DateTime.UtcNow,
+ ExpiresAtUtc: DateTime.UtcNow.AddHours(4)
+ );
+
+ // Act
+ var notification = _formatter.FormatProposalNotification(proposal, "finlytic_broadcast");
+
+ // Assert
+ Assert.Equal("finlytic_broadcast", notification.Topic);
+ Assert.Contains("🟢 Neuer Trade-Vorschlag: NVDA (Long)", notification.Title);
+ Assert.Equal(4, notification.Priority); // Score >= 80 -> Priority 4
+ Assert.Contains("chart_with_upwards_trend", notification.Tags!);
+ Assert.Contains("moneybag", notification.Tags!);
+ Assert.Contains("**Strategie:** TrendPullbackFvg", notification.Message);
+ Assert.Contains("125,00", notification.Message.Replace('.', ','));
+ Assert.Contains("HSBC", notification.Message);
+ Assert.Contains("Bruch des lokalen Abwärtstrends", notification.Message);
+ }
+
+ [Fact]
+ public void FormatProposalNotification_ShortSell_FormatsCorrectly()
+ {
+ // Arrange
+ var proposal = new TradeProposalDto(
+ ProposalId: Guid.NewGuid(),
+ UnderlyingIsin: "US88160R1014",
+ Symbol: "TSLA",
+ StrategyKey: "SmcLiquiditySweep",
+ Direction: SignalDirection.Sell,
+ QualityScore: 74m,
+ CompositeScore: 72.0m,
+ CurrentPrice: 210.00m,
+ EntryPrice: 209.50m,
+ InvalidationPrice: 216.00m,
+ ExitPlan: new ExitPlan(
+ StrategyType: ExitStrategyType.FixedSingleTarget,
+ InitialStopLoss: 216.00m,
+ TakeProfitStages: [new TakeProfitStage(1, 196.50m, 1.0m, 2.0m, "TP1")]
+ ),
+ SelectedDerivative: null,
+ AiValidation: new AiValidationResultDto(
+ IsApproved: true,
+ Confidence: null,
+ Source: ValidationSource.RuleBased,
+ ThesisSummary: "Bärischer Liquidity Sweep über dem Vortagshoch mit starker Ablehnung.",
+ InvalidationReason: "",
+ KeyCatalysts: [],
+ IdentifiedRisks: []
+ ),
+ CreatedAtUtc: DateTime.UtcNow,
+ ExpiresAtUtc: DateTime.UtcNow.AddHours(4)
+ );
+
+ // Act
+ var notification = _formatter.FormatProposalNotification(proposal, "finlytic_broadcast");
+
+ // Assert
+ Assert.Equal("finlytic_broadcast", notification.Topic);
+ Assert.Contains("🔴 Neuer Trade-Vorschlag: TSLA (Short)", notification.Title);
+ Assert.Equal(3, notification.Priority); // Score < 80 -> Priority 3
+ Assert.Contains("chart_with_downwards_trend", notification.Tags!);
+ }
+
+ [Fact]
+ public void FormatTradeStatusNotification_Tp1Hit_FormatsCorrectlyForUser()
+ {
+ // Arrange
+ var trade = new ActiveTradeDto(
+ TradeId: Guid.NewGuid(),
+ ProposalId: Guid.NewGuid(),
+ UserId: Guid.NewGuid(),
+ UnderlyingIsin: "US0378331005",
+ Symbol: "AAPL",
+ DerivativeIsin: null,
+ DerivativeWkn: null,
+ ExecutionMode: ExecutionMode.ManualTradeRepublic,
+ InstrumentType: InstrumentCategoryType.Stock,
+ Direction: SignalDirection.Buy,
+ Status: TradeStatus.Tp1Hit,
+ AverageBuyIn: 150.00m,
+ TotalQuantity: 10m,
+ InitialStopLoss: 145.00m,
+ CurrentStopLoss: 151.00m,
+ CurrentPrice: 160.00m,
+ UnrealizedPnlEur: 100.00m,
+ UnrealizedPnlPercent: 6.67m,
+ RealizedPnlEur: 0m,
+ ExitPlan: new ExitPlan(ExitStrategyType.StagedScaleOutWithBreakEven, 145.00m, []),
+ Fills: [],
+ OpenedAtUtc: DateTime.UtcNow.AddDays(-1),
+ ClosedAtUtc: null
+ );
+
+ // Act
+ var notification = _formatter.FormatTradeStatusNotification(trade, "finlytic_lars");
+
+ // Assert
+ Assert.Equal("finlytic_lars", notification.Topic);
+ Assert.Contains("🎯 Teilgewinn erreicht (TP1): AAPL", notification.Title);
+ Assert.Equal(4, notification.Priority);
+ Assert.Contains("tada", notification.Tags!);
+ Assert.Contains("Break-Even gesichert", notification.Message);
+ }
+
+ [Fact]
+ public void FormatTradeStatusNotification_StoppedOut_FormatsCorrectlyForUser()
+ {
+ // Arrange
+ var trade = new ActiveTradeDto(
+ TradeId: Guid.NewGuid(),
+ ProposalId: Guid.NewGuid(),
+ UserId: Guid.NewGuid(),
+ UnderlyingIsin: "US0378331005",
+ Symbol: "AAPL",
+ DerivativeIsin: null,
+ DerivativeWkn: null,
+ ExecutionMode: ExecutionMode.ManualTradeRepublic,
+ InstrumentType: InstrumentCategoryType.Stock,
+ Direction: SignalDirection.Buy,
+ Status: TradeStatus.StoppedOut,
+ AverageBuyIn: 150.00m,
+ TotalQuantity: 10m,
+ InitialStopLoss: 145.00m,
+ CurrentStopLoss: 145.00m,
+ CurrentPrice: 144.50m,
+ UnrealizedPnlEur: 0m,
+ UnrealizedPnlPercent: 0m,
+ RealizedPnlEur: -55.00m,
+ ExitPlan: new ExitPlan(ExitStrategyType.FixedSingleTarget, 145.00m, []),
+ Fills: [],
+ OpenedAtUtc: DateTime.UtcNow.AddDays(-1),
+ ClosedAtUtc: DateTime.UtcNow
+ );
+
+ // Act
+ var notification = _formatter.FormatTradeStatusNotification(trade, "finlytic_john");
+
+ // Assert
+ Assert.Equal("finlytic_john", notification.Topic);
+ Assert.Contains("🛑 Stop-Loss ausgelöst: AAPL", notification.Title);
+ Assert.Equal(4, notification.Priority);
+ Assert.Contains("warning", notification.Tags!);
+ Assert.Contains("risikokontrolliert geschlossen", notification.Message);
+ }
+
+ [Fact]
+ public void FormatBotTradeNotification_Active_FormatsCorrectly()
+ {
+ // Arrange
+ var botTrade = new BotTradeOrderDto(
+ OrderId: Guid.NewGuid(),
+ ProposalId: Guid.NewGuid(),
+ Isin: "US5949181045",
+ Symbol: "MSFT",
+ Venue: BotExecutionVenue.AlpacaPaperTrading,
+ AlpacaOrderId: "alpaca_123",
+ ClientOrderId: "client_123",
+ Direction: SignalDirection.Buy,
+ RequestedQuantity: 5m,
+ FilledQuantity: 5m,
+ EntryPrice: 420.00m,
+ AverageBuyIn: 420.50m,
+ InitialStopLoss: 410.00m,
+ CurrentStopLoss: 410.00m,
+ TakeProfit1: 440.00m,
+ TakeProfit2: 460.00m,
+ CurrentPrice: 425.00m,
+ UnrealizedPnlEur: 22.50m,
+ RealizedPnlEur: 0m,
+ Status: BotPositionStatus.Active,
+ ExitPlan: new ExitPlan(ExitStrategyType.FixedSingleTarget, 410.00m, []),
+ CreatedAtUtc: DateTime.UtcNow,
+ FilledAtUtc: DateTime.UtcNow,
+ ClosedAtUtc: null
+ );
+
+ // Act
+ var notification = _formatter.FormatBotTradeNotification(botTrade, "finlytic_bot");
+
+ // Assert
+ Assert.Equal("finlytic_bot", notification.Topic);
+ Assert.Contains("🤖 Bot Trade [Active]: MSFT (Long)", notification.Title);
+ Assert.Contains("robot", notification.Tags!);
+ }
+
+ [Fact]
+ public void FormatNewsNotification_PositiveSentiment_FormatsCorrectly()
+ {
+ // Arrange
+ var article = new NewsArticleDto
+ {
+ Id = Guid.NewGuid(),
+ Title = "NVIDIA Reports Record Q4 Revenue Driven by AI Chip Demand",
+ Summary = "NVIDIA exceeded analyst expectations across data center and gaming segments.",
+ PublishedAt = DateTime.UtcNow,
+ SourceUrl = "https://example.com/news/nvda-q4",
+ Sentiment = "POSITIVE",
+ SentimentScore = 0.88,
+ Confidence = 0.92,
+ MatchedAssets =
+ [
+ new MatchedAssetDto { Name = "NVIDIA Corp.", Isin = "US67066G1040" }
+ ]
+ };
+
+ // Act
+ var notification = _formatter.FormatNewsNotification(article, "finlytic_news");
+
+ // Assert
+ Assert.Equal("finlytic_news", notification.Topic);
+ Assert.Contains("🟢 News (POSITIVE): NVIDIA Corp.", notification.Title);
+ Assert.Equal(4, notification.Priority); // High confidence + high positive score -> Priority 4
+ Assert.Contains("newspaper", notification.Tags!);
+ Assert.Contains("chart_with_upwards_trend", notification.Tags!);
+ Assert.Contains("NVIDIA Reports Record Q4 Revenue", notification.Message);
+ Assert.Contains("0.88", notification.Message);
+ Assert.Equal("https://example.com/news/nvda-q4", notification.ClickUrl);
+ }
+
+ [Fact]
+ public void FormatNewsNotification_NegativeSentiment_FormatsCorrectly()
+ {
+ // Arrange
+ var article = new NewsArticleDto
+ {
+ Id = Guid.NewGuid(),
+ Title = "Tesla Faces Supply Chain Delays and Reduced Delivery Targets",
+ Summary = "Tesla lowers annual guidance following factory shutdowns.",
+ PublishedAt = DateTime.UtcNow,
+ SourceUrl = "https://example.com/news/tsla-delays",
+ Sentiment = "NEGATIVE",
+ SentimentScore = -0.75,
+ Confidence = 0.85,
+ MatchedAssets =
+ [
+ new MatchedAssetDto { Name = "Tesla Inc.", Isin = "US88160R1014" }
+ ]
+ };
+
+ // Act
+ var notification = _formatter.FormatNewsNotification(article, "finlytic_news");
+
+ // Assert
+ Assert.Equal("finlytic_news", notification.Topic);
+ Assert.Contains("🔴 News (NEGATIVE): Tesla Inc.", notification.Title);
+ Assert.Equal(4, notification.Priority);
+ Assert.Contains("newspaper", notification.Tags!);
+ Assert.Contains("chart_with_downwards_trend", notification.Tags!);
+ Assert.Contains("Tesla Faces Supply Chain Delays", notification.Message);
+ Assert.Contains("-0.75", notification.Message);
+ Assert.Equal("https://example.com/news/tsla-delays", notification.ClickUrl);
+ }
+}
diff --git a/FinlyticNotify/Database/NotifyDbContext.cs b/FinlyticNotify/Database/NotifyDbContext.cs
new file mode 100644
index 0000000..2a080dc
--- /dev/null
+++ b/FinlyticNotify/Database/NotifyDbContext.cs
@@ -0,0 +1,34 @@
+using FinlyticCore.Database;
+using FinlyticCore.Entities.Settings;
+using Microsoft.EntityFrameworkCore;
+
+namespace FinlyticNotify.Database;
+
+///
+/// Entity Framework DbContext used by FinlyticNotify exclusively for its own database (finlytic_notify)
+/// and dynamic settings.
+///
+public class NotifyDbContext : DbContext, ISettingsDbContext
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public NotifyDbContext(DbContextOptions options) : base(options)
+ {
+ }
+
+ /// Dynamic settings table for FinlyticNotify.
+ public DbSet DynamicSettings => Set();
+
+ ///
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.HasIndex(e => e.Key).IsUnique();
+ });
+ }
+}
diff --git a/FinlyticNotify/Dockerfile b/FinlyticNotify/Dockerfile
new file mode 100644
index 0000000..7b05321
--- /dev/null
+++ b/FinlyticNotify/Dockerfile
@@ -0,0 +1,22 @@
+FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
+USER $APP_UID
+WORKDIR /app
+
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+ARG BUILD_CONFIGURATION=Release
+WORKDIR /src
+COPY ["FinlyticNotify/FinlyticNotify.csproj", "FinlyticNotify/"]
+COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"]
+RUN dotnet restore "FinlyticNotify/FinlyticNotify.csproj"
+COPY . .
+WORKDIR "/src/FinlyticNotify"
+RUN dotnet build "FinlyticNotify.csproj" -c $BUILD_CONFIGURATION -o /app/build
+
+FROM build AS publish
+ARG BUILD_CONFIGURATION=Release
+RUN dotnet publish "FinlyticNotify.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "FinlyticNotify.dll"]
diff --git a/FinlyticNotify/FinlyticNotify.csproj b/FinlyticNotify/FinlyticNotify.csproj
new file mode 100644
index 0000000..2890f99
--- /dev/null
+++ b/FinlyticNotify/FinlyticNotify.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net10.0
+ enable
+ enable
+ Linux
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FinlyticNotify/Migrations/20260825200608_Init.Designer.cs b/FinlyticNotify/Migrations/20260825200608_Init.Designer.cs
new file mode 100644
index 0000000..9171dd3
--- /dev/null
+++ b/FinlyticNotify/Migrations/20260825200608_Init.Designer.cs
@@ -0,0 +1,61 @@
+//
+using System;
+using FinlyticNotify.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 FinlyticNotify.Migrations
+{
+ [DbContext(typeof(NotifyDbContext))]
+ [Migration("20260825200608_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");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/FinlyticNotify/Migrations/20260825200608_Init.cs b/FinlyticNotify/Migrations/20260825200608_Init.cs
new file mode 100644
index 0000000..12eb821
--- /dev/null
+++ b/FinlyticNotify/Migrations/20260825200608_Init.cs
@@ -0,0 +1,43 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace FinlyticNotify.Migrations
+{
+ ///
+ public partial class Init : 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/FinlyticNotify/Migrations/NotifyDbContextModelSnapshot.cs b/FinlyticNotify/Migrations/NotifyDbContextModelSnapshot.cs
new file mode 100644
index 0000000..65f30ae
--- /dev/null
+++ b/FinlyticNotify/Migrations/NotifyDbContextModelSnapshot.cs
@@ -0,0 +1,58 @@
+//
+using System;
+using FinlyticNotify.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace FinlyticNotify.Migrations
+{
+ [DbContext(typeof(NotifyDbContext))]
+ partial class NotifyDbContextModelSnapshot : 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("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");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/FinlyticNotify/Program.cs b/FinlyticNotify/Program.cs
new file mode 100644
index 0000000..1c7b4a7
--- /dev/null
+++ b/FinlyticNotify/Program.cs
@@ -0,0 +1,64 @@
+using System;
+using System.Net.Http;
+using FinlyticCore.Database;
+using FinlyticCore.Services;
+using FinlyticNotify.Database;
+using FinlyticNotify.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+var builder = Host.CreateApplicationBuilder(args);
+
+// 1. Register DbContext & Settings Provider (Read-Only user/trade queries + dynamic settings)
+builder.Services.AddDbContext(options =>
+ options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
+builder.Services.AddScoped(sp => sp.GetRequiredService());
+
+// 2. Register Core Services & Logger
+builder.Services.AddSingleton();
+builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>));
+
+// 3. Register In-Memory Cache
+builder.Services.AddMemoryCache();
+
+// 4. Register HTTP Client & ntfy Push Client
+builder.Services.AddHttpClient()
+ .ConfigureHttpClient(client =>
+ {
+ client.Timeout = TimeSpan.FromSeconds(10);
+ });
+
+// 5. Register Domain Services
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
+// 6. Register MQTT Listener & Background Service
+builder.Services.AddSingleton();
+builder.Services.AddHostedService(sp => sp.GetRequiredService());
+
+var host = builder.Build();
+
+using (var scope = host.Services.CreateScope())
+{
+ try
+ {
+ var context = scope.ServiceProvider.GetRequiredService();
+ var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? "";
+ await context.MigrateWithBootstrapAsync(connStr);
+ }
+
+ catch (Exception ex)
+ {
+ var logger = scope.ServiceProvider.GetRequiredService>();
+ logger.LogError(ex, "An error occurred during database migration/seeding.");
+ }
+}
+
+Console.WriteLine("=================================================");
+Console.WriteLine(" FinlyticNotify - Push Notification Service (ntfy)");
+Console.WriteLine(" Listening exclusively to MQTT Trade Events");
+Console.WriteLine("=================================================");
+
+await host.RunAsync();
diff --git a/FinlyticNotify/Services/NotificationFormatter.cs b/FinlyticNotify/Services/NotificationFormatter.cs
new file mode 100644
index 0000000..b97557a
--- /dev/null
+++ b/FinlyticNotify/Services/NotificationFormatter.cs
@@ -0,0 +1,260 @@
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using FinlyticCore.Dtos.Bot;
+using FinlyticCore.Dtos.News;
+using FinlyticCore.Dtos.TechnicalAnalysis;
+using FinlyticCore.Dtos.Trading;
+
+namespace FinlyticNotify.Services;
+
+///
+/// Service interface for transforming trading and news events into structured ntfy push notifications.
+///
+public interface INotificationFormatter
+{
+ ///
+ /// Formats a new trade proposal into a high-priority opportunity notification.
+ ///
+ NtfyNotification FormatProposalNotification(TradeProposalDto proposal, string targetTopic);
+
+ ///
+ /// Formats an active trade lifecycle change into a user-specific status notification.
+ ///
+ NtfyNotification FormatTradeStatusNotification(ActiveTradeDto trade, string targetTopic);
+
+ ///
+ /// Formats an automated paper-trading bot execution event into a notification.
+ ///
+ NtfyNotification FormatBotTradeNotification(BotTradeOrderDto botTrade, string targetTopic);
+
+ ///
+ /// Formats an analyzed news article with sentiment evaluation into a push notification.
+ ///
+ NtfyNotification FormatNewsNotification(NewsArticleDto article, string targetTopic);
+}
+
+///
+/// Implementation of that creates emoji-rich Markdown messages
+/// formatted for the ntfy mobile/web applications.
+///
+public class NotificationFormatter : INotificationFormatter
+{
+ ///
+ public NtfyNotification FormatProposalNotification(TradeProposalDto proposal, string targetTopic)
+ {
+ bool isBuy = proposal.Direction == SignalDirection.Buy;
+ string dirEmoji = isBuy ? "🟢" : "🔴";
+ string dirText = isBuy ? "Long" : "Short";
+ string title = $"{dirEmoji} Neuer Trade-Vorschlag: {proposal.Symbol} ({dirText})";
+
+ var tags = new List
+ {
+ isBuy ? "chart_with_upwards_trend" : "chart_with_downwards_trend",
+ "moneybag",
+ "dart"
+ };
+
+ int priority = proposal.CompositeScore >= 80m ? 4 : 3;
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"**Strategie:** {proposal.StrategyKey} | **Score:** {proposal.CompositeScore:F1}/100");
+ sb.AppendLine($"**Einstieg:** {proposal.EntryPrice:F2} €");
+ sb.AppendLine($"**Stop-Loss:** {proposal.InvalidationPrice:F2} €");
+
+ var tp1 = proposal.ExitPlan?.TakeProfitStages?.FirstOrDefault();
+ if (tp1 != null)
+ {
+ sb.AppendLine($"**Ziel (TP1):** {tp1.TargetPrice:F2} € ({tp1.Description})");
+ }
+
+ if (proposal.SelectedDerivative != null)
+ {
+ sb.AppendLine($"**Knock-Out:** {proposal.SelectedDerivative.Issuer} ({proposal.SelectedDerivative.OptionType}, Hebel: {proposal.SelectedDerivative.Leverage:F1}x)");
+ }
+
+ if (!string.IsNullOrWhiteSpace(proposal.AiValidation?.ThesisSummary))
+ {
+ sb.AppendLine();
+ sb.AppendLine($"**KI-These:** {proposal.AiValidation.ThesisSummary}");
+ }
+
+ return new NtfyNotification(
+ Topic: targetTopic,
+ Title: title,
+ Message: sb.ToString().TrimEnd(),
+ Priority: priority,
+ Tags: tags
+ );
+ }
+
+ ///
+ public NtfyNotification FormatTradeStatusNotification(ActiveTradeDto trade, string targetTopic)
+ {
+ string dirText = trade.Direction == SignalDirection.Buy ? "Long" : "Short";
+
+ return trade.Status switch
+ {
+ TradeStatus.Active or TradeStatus.Proposed => new NtfyNotification(
+ Topic: targetTopic,
+ Title: $"⚡ Trade aktiv: {trade.Symbol} ({dirText})",
+ Message: $"**Buy-In:** {trade.AverageBuyIn:F2} € | **Menge:** {trade.TotalQuantity:F2}\n" +
+ $"**Initialer Stop-Loss:** {trade.InitialStopLoss:F2} €\n" +
+ $"**Aktueller Kurs:** {trade.CurrentPrice:F2} €",
+ Priority: 3,
+ Tags: ["zap", "white_check_mark"]
+ ),
+
+ TradeStatus.Tp1Hit => new NtfyNotification(
+ Topic: targetTopic,
+ Title: $"🎯 Teilgewinn erreicht (TP1): {trade.Symbol} (+{trade.UnrealizedPnlPercent:F1}%)",
+ Message: $"**Gewinn:** +{trade.UnrealizedPnlEur:F2} € (+{trade.UnrealizedPnlPercent:F1}%)\n" +
+ $"**Aktueller Kurs:** {trade.CurrentPrice:F2} € (Buy-In: {trade.AverageBuyIn:F2} €)\n" +
+ $"**Neuer Stop-Loss:** {trade.CurrentStopLoss:F2} € (Break-Even gesichert)",
+ Priority: 4,
+ Tags: ["tada", "dart", "chart_with_upwards_trend"]
+ ),
+
+ TradeStatus.Tp2Hit => new NtfyNotification(
+ Topic: targetTopic,
+ Title: $"🏆 Vollziel erreicht (TP2): {trade.Symbol} (+{trade.RealizedPnlEur:F2} €)",
+ Message: $"**Realisierter Gewinn:** +{trade.RealizedPnlEur:F2} €\n" +
+ $"**Schlusskurs:** {trade.CurrentPrice:F2} € (Buy-In: {trade.AverageBuyIn:F2} €)\n" +
+ $"**Status:** Trade erfolgreich mit Maximalziel abgeschlossen!",
+ Priority: 4,
+ Tags: ["trophy", "money_with_wings", "star2"]
+ ),
+
+ TradeStatus.StoppedOut => new NtfyNotification(
+ Topic: targetTopic,
+ Title: $"🛑 Stop-Loss ausgelöst: {trade.Symbol} ({trade.RealizedPnlEur:F2} €)",
+ Message: $"**Verlust:** {trade.RealizedPnlEur:F2} €\n" +
+ $"**Ausstiegskurs:** {trade.CurrentPrice:F2} € (Stop war bei {trade.CurrentStopLoss:F2} €)\n" +
+ $"**Status:** Position durch Stop-Loss risikokontrolliert geschlossen.",
+ Priority: 4,
+ Tags: ["octagonal_sign", "warning", "shield"]
+ ),
+
+ TradeStatus.Closed => new NtfyNotification(
+ Topic: targetTopic,
+ Title: $"🏁 Trade geschlossen: {trade.Symbol} (G/V: {trade.RealizedPnlEur:F2} €)",
+ Message: $"**Realisierter G/V:** {trade.RealizedPnlEur:F2} €\n" +
+ $"**Schlusskurs:** {trade.CurrentPrice:F2} € (Buy-In: {trade.AverageBuyIn:F2} €)",
+ Priority: 3,
+ Tags: ["checkered_flag", "information_source"]
+ ),
+
+ _ => new NtfyNotification(
+ Topic: targetTopic,
+ Title: $"🛡️ Trade Update: {trade.Symbol} ({trade.Status})",
+ Message: $"**Aktueller Stop-Loss:** {trade.CurrentStopLoss:F2} €\n" +
+ $"**Aktueller Kurs:** {trade.CurrentPrice:F2} € (Buy-In: {trade.AverageBuyIn:F2} €)\n" +
+ $"**Unrealisierter G/V:** {trade.UnrealizedPnlEur:F2} € ({trade.UnrealizedPnlPercent:F1}%)",
+ Priority: 2,
+ Tags: ["shield", "chart"]
+ )
+ };
+ }
+
+ ///
+ public NtfyNotification FormatBotTradeNotification(BotTradeOrderDto botTrade, string targetTopic)
+ {
+ string dirText = botTrade.Direction == SignalDirection.Buy ? "Long" : "Short";
+ string title = $"🤖 Bot Trade [{botTrade.Status}]: {botTrade.Symbol} ({dirText})";
+
+ var tags = new List { "robot", "chart" };
+ if (botTrade.Status == BotPositionStatus.Tp1Hit || botTrade.Status == BotPositionStatus.Tp2Hit) tags.Add("dart");
+ if (botTrade.Status == BotPositionStatus.StoppedOut) tags.Add("warning");
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"**Venue:** {botTrade.Venue} | **Status:** {botTrade.Status}");
+ sb.AppendLine($"**Buy-In:** {botTrade.AverageBuyIn:F2} € | **Menge:** {botTrade.FilledQuantity:F2}");
+ sb.AppendLine($"**Stop-Loss:** {botTrade.CurrentStopLoss:F2} €");
+ sb.AppendLine($"**Aktueller Kurs:** {botTrade.CurrentPrice:F2} €");
+
+ if (botTrade.Status == BotPositionStatus.Closed || botTrade.Status == BotPositionStatus.StoppedOut || botTrade.Status == BotPositionStatus.Tp2Hit)
+ {
+ sb.AppendLine($"**Realisierter G/V:** {botTrade.RealizedPnlEur:F2} €");
+ }
+ else
+ {
+ sb.AppendLine($"**Unrealisierter G/V:** {botTrade.UnrealizedPnlEur:F2} €");
+ }
+
+ return new NtfyNotification(
+ Topic: targetTopic,
+ Title: title,
+ Message: sb.ToString().TrimEnd(),
+ Priority: 3,
+ Tags: tags
+ );
+ }
+
+ ///
+ public NtfyNotification FormatNewsNotification(NewsArticleDto article, string targetTopic)
+ {
+ string sentimentLabel = (article.Sentiment ?? "NEUTRAL").ToUpperInvariant();
+ double score = article.SentimentScore ?? 0.0;
+ double confidence = article.Confidence ?? 0.0;
+
+ string sentimentEmoji = sentimentLabel switch
+ {
+ "POSITIVE" => "🟢",
+ "NEGATIVE" => "🔴",
+ _ => "⚪"
+ };
+
+ string primaryAsset = article.MatchedAssets?.FirstOrDefault()?.Name
+ ?? article.MatchedAssets?.FirstOrDefault()?.Isin
+ ?? "Markt";
+
+ string title = $"{sentimentEmoji} News ({sentimentLabel}): {primaryAsset}";
+
+ var tags = new List { "newspaper" };
+ if (sentimentLabel == "POSITIVE")
+ {
+ tags.Add("chart_with_upwards_trend");
+ tags.Add("tada");
+ }
+ else if (sentimentLabel == "NEGATIVE")
+ {
+ tags.Add("chart_with_downwards_trend");
+ tags.Add("warning");
+ }
+ else
+ {
+ tags.Add("information_source");
+ }
+
+ int priority = (confidence >= 0.8 && Math.Abs(score) >= 0.6) ? 4 : 3;
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"**{article.Title}**");
+ sb.AppendLine();
+ sb.AppendLine($"**Sentiment:** {sentimentLabel} (Score: {score:+0.00;-0.00;0.00} | Konfidenz: {confidence:P0})");
+
+ if (article.MatchedAssets != null && article.MatchedAssets.Count > 0)
+ {
+ var assetList = string.Join(", ", article.MatchedAssets.Select(a => $"{a.Name} ({a.Isin})"));
+ sb.AppendLine($"**Assets:** {assetList}");
+ }
+
+ if (!string.IsNullOrWhiteSpace(article.Summary))
+ {
+ sb.AppendLine();
+ sb.AppendLine($"_{article.Summary}_");
+ }
+
+ sb.AppendLine();
+ sb.AppendLine($"**Veröffentlicht:** {article.PublishedAt:dd.MM.yyyy HH:mm} UTC");
+
+ return new NtfyNotification(
+ Topic: targetTopic,
+ Title: title,
+ Message: sb.ToString().TrimEnd(),
+ Priority: priority,
+ Tags: tags,
+ ClickUrl: !string.IsNullOrWhiteSpace(article.SourceUrl) ? article.SourceUrl : null
+ );
+ }
+}
diff --git a/FinlyticNotify/Services/NotifyMqttClient.cs b/FinlyticNotify/Services/NotifyMqttClient.cs
new file mode 100644
index 0000000..d450405
--- /dev/null
+++ b/FinlyticNotify/Services/NotifyMqttClient.cs
@@ -0,0 +1,337 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using FinlyticCore.Dtos;
+using FinlyticCore.Dtos.Bot;
+using FinlyticCore.Dtos.News;
+using FinlyticCore.Dtos.Settings;
+using FinlyticCore.Dtos.Trading;
+using FinlyticCore.Models;
+using FinlyticCore.Services;
+using FinlyticCore.Util;
+using FinlyticNotify.Settings;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace FinlyticNotify.Services;
+
+///
+/// Managed MQTT client for FinlyticNotify. Listens strictly to existing MQTT broadcast topics,
+/// resolves trade ownership, and dispatches rich push notifications via ntfy.
+///
+public class NotifyMqttClient : ManagedMqttClient, IHostedService
+{
+ private readonly IConfiguration _configuration;
+ private readonly IServiceScopeFactory _scopeFactory;
+ private readonly INtfyClient _ntfyClient;
+ private readonly INotificationFormatter _formatter;
+ private readonly IUserTradeResolver _userTradeResolver;
+ private readonly ISettingsService? _settingsService;
+ private readonly IFinlyticLogger? _finlyticLogger;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public NotifyMqttClient(
+ IConfiguration configuration,
+ IServiceScopeFactory scopeFactory,
+ INtfyClient ntfyClient,
+ INotificationFormatter formatter,
+ IUserTradeResolver userTradeResolver,
+ ILogger logger,
+ ISettingsService? settingsService = null,
+ IFinlyticLogger? finlyticLogger = null) : base(logger)
+ {
+ _configuration = configuration;
+ _scopeFactory = scopeFactory;
+ _ntfyClient = ntfyClient;
+ _formatter = formatter;
+ _userTradeResolver = userTradeResolver;
+ _settingsService = settingsService;
+ _finlyticLogger = finlyticLogger;
+ _logger = logger;
+ }
+
+ ///
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ var config = MqttConfiguration.FromConfiguration(_configuration, "FinlyticNotify");
+ _logger.LogInformation("[NotifyMqttClient] Starting FinlyticNotify MQTT client (Broker: {Host}:{Port}, ClientId: {ClientId})",
+ config.Host, config.Port, config.ClientId);
+
+ await ConnectAsync(config);
+ }
+
+ ///
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("[NotifyMqttClient] Stopping FinlyticNotify MQTT client.");
+ if (_finlyticLogger != null)
+ {
+ await _finlyticLogger.LogInfoAsync(NotifySettingKeys.MqttChannel, "[NotifyMqttClient] Stopping FinlyticNotify MQTT client.");
+ }
+ await DisconnectAsync();
+ }
+
+ ///
+ protected override async Task OnConnectedAsync()
+ {
+ _logger.LogInformation("[NotifyMqttClient] Connected to MQTT broker. Subscribing to trade event topics...");
+
+ await SubscribeAsync(MqttTopics.ResponseWildcard);
+
+ // 1. Subscribe to Trade Proposals (New Trades / Setups)
+ await SubscribeAsync(MqttTopics.EngineProposalsCreated);
+
+ // 2. Subscribe to Trade Lifecycle Status Changes (Fills, SL-Updates, TPs, Exits)
+ await SubscribeAsync(MqttTopics.EngineTradesStatusChanged);
+
+ // 3. Subscribe to Bot Paper-Trading Streams
+ await SubscribeAsync(MqttTopics.BotTradesStream);
+
+ // 4. Subscribe to News Status Update requests to capture analyzed news events
+ await SubscribeAsync(
+ MqttTopics.RequestFilter(MqttTopics.Channels.NewsUpdateStatus), HandleNewsStatusUpdateRequestAsync);
+
+ // 5. Subscribe to Service Health Ping for fleet monitoring
+ await SubscribeAsync