From c5d7d359baa9767e296ff1f12f17177b52634e60 Mon Sep 17 00:00:00 2001 From: Kleidukos Date: Tue, 1 Sep 2026 17:38:01 +0200 Subject: [PATCH] feat(notify): add FinlyticNotify push notification microservice and test suite --- Finlytic.sln | 28 ++ .../FinlyticNotify.Tests.csproj | 27 ++ .../Services/NotificationFormatterTests.cs | 312 ++++++++++++++++ FinlyticNotify/Database/NotifyDbContext.cs | 34 ++ FinlyticNotify/Dockerfile | 22 ++ FinlyticNotify/FinlyticNotify.csproj | 27 ++ .../20260825200608_Init.Designer.cs | 61 ++++ .../Migrations/20260825200608_Init.cs | 43 +++ .../NotifyDbContextModelSnapshot.cs | 58 +++ FinlyticNotify/Program.cs | 64 ++++ .../Services/NotificationFormatter.cs | 260 ++++++++++++++ FinlyticNotify/Services/NotifyMqttClient.cs | 337 ++++++++++++++++++ FinlyticNotify/Services/NtfyClient.cs | 188 ++++++++++ FinlyticNotify/Services/UserTradeResolver.cs | 113 ++++++ FinlyticNotify/Settings/NotifySettingKeys.cs | 37 ++ FinlyticNotify/appsettings.json | 26 ++ docker-compose.ntfy.yml | 40 +++ 17 files changed, 1677 insertions(+) create mode 100644 FinlyticNotify.Tests/FinlyticNotify.Tests.csproj create mode 100644 FinlyticNotify.Tests/Services/NotificationFormatterTests.cs create mode 100644 FinlyticNotify/Database/NotifyDbContext.cs create mode 100644 FinlyticNotify/Dockerfile create mode 100644 FinlyticNotify/FinlyticNotify.csproj create mode 100644 FinlyticNotify/Migrations/20260825200608_Init.Designer.cs create mode 100644 FinlyticNotify/Migrations/20260825200608_Init.cs create mode 100644 FinlyticNotify/Migrations/NotifyDbContextModelSnapshot.cs create mode 100644 FinlyticNotify/Program.cs create mode 100644 FinlyticNotify/Services/NotificationFormatter.cs create mode 100644 FinlyticNotify/Services/NotifyMqttClient.cs create mode 100644 FinlyticNotify/Services/NtfyClient.cs create mode 100644 FinlyticNotify/Services/UserTradeResolver.cs create mode 100644 FinlyticNotify/Settings/NotifySettingKeys.cs create mode 100644 FinlyticNotify/appsettings.json create mode 100644 docker-compose.ntfy.yml 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(MqttTopics.RequestFilter(MqttTopics.Channels.HealthPing), HandleHealthPingTopicAsync); + + // 6. Subscribe to Dynamic Settings RPC Channels + await SubscribeRpcAsync>( + MqttTopics.RequestFilter(MqttTopics.Channels.NotifySettingsGetAll), HandleSettingsGetAllRpcAsync); + await SubscribeRpcAsync, List>( + MqttTopics.RequestFilter(MqttTopics.Channels.NotifySettingsUpdate), HandleSettingsUpdateRpcAsync); + + // 7. Wire structured log broadcasting over MQTT + FinlyticLogBroadcaster.OnLogPublished = async (logDto) => + { + if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticNotify", StringComparison.OrdinalIgnoreCase)) + { + await PublishAsync(MqttTopics.Logs("FinlyticNotify"), logDto); + } + }; + + if (_finlyticLogger != null) + { + await _finlyticLogger.LogInfoAsync(NotifySettingKeys.MqttChannel, + "[NotifyMqttClient] FinlyticNotify MQTT client connected and subscribed to trade and news events."); + } + } + + /// + protected override async Task OnMessageReceivedAsync(string topic, string payloadStr) + { + if (string.IsNullOrWhiteSpace(topic) || string.IsNullOrWhiteSpace(payloadStr)) return; + + string topicPrefix = "finlytic"; + if (_settingsService != null) + { + topicPrefix = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyTopicPrefix); + } + else + { + topicPrefix = _configuration.GetValue("Ntfy:TopicPrefix") ?? NotifySettingKeys.NtfyTopicPrefix.DefaultValue; + } + + topicPrefix = topicPrefix.Trim('/'); + + try + { + // Case A: New Trade Proposals created by FinlyticEngine + if (topic.Equals(MqttTopics.EngineProposalsCreated, StringComparison.OrdinalIgnoreCase)) + { + await HandleProposalCreatedAsync(payloadStr, topicPrefix); + } + // Case B: Trade Status Changed (Lifecycle updates for active trades) + else if (topic.Equals(MqttTopics.EngineTradesStatusChanged, StringComparison.OrdinalIgnoreCase)) + { + await HandleTradeStatusChangedAsync(payloadStr, topicPrefix); + } + // Case C: Bot Paper-Trading Execution stream + else if (topic.Equals(MqttTopics.BotTradesStream, StringComparison.OrdinalIgnoreCase)) + { + await HandleBotTradeStreamAsync(payloadStr, topicPrefix); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[NotifyMqttClient] Unexpected error handling message on topic {Topic}", topic); + if (_finlyticLogger != null) + { + await _finlyticLogger.LogErrorAsync(NotifySettingKeys.NotifyChannel, ex, + "[NotifyMqttClient] Unexpected error handling message on topic {Topic}", topic); + } + } + } + + private async Task HandleProposalCreatedAsync(string payloadStr, string topicPrefix) + { + bool notifyOnProposals = true; + decimal minScore = 70.0m; + string broadcastChannel = "broadcast"; + + if (_settingsService != null) + { + notifyOnProposals = await _settingsService.GetSettingAsync(NotifySettingKeys.NotifyOnProposals); + minScore = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyMinProposalScore); + broadcastChannel = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyBroadcastChannel); + } + else + { + notifyOnProposals = _configuration.GetValue("Ntfy:NotifyOnProposals", true); + minScore = _configuration.GetValue("Ntfy:MinProposalScore", 70.0m); + broadcastChannel = _configuration.GetValue("Ntfy:BroadcastChannel") ?? "broadcast"; + } + + if (!notifyOnProposals) return; + + var proposal = JsonSerializer.Deserialize(payloadStr, DefaultJsonOptions); + if (proposal == null) return; + + if (proposal.CompositeScore < minScore) + { + _logger.LogDebug("[NotifyMqttClient] Skipping proposal {ProposalId}: CompositeScore {Score} < MinScore {MinScore}", + proposal.ProposalId, proposal.CompositeScore, minScore); + return; + } + + string targetTopic = $"{topicPrefix}_{broadcastChannel}"; + var notification = _formatter.FormatProposalNotification(proposal, targetTopic); + await _ntfyClient.SendNotificationAsync(notification); + } + + private async Task HandleTradeStatusChangedAsync(string payloadStr, string topicPrefix) + { + bool notifyOnTradeUpdates = true; + if (_settingsService != null) + { + notifyOnTradeUpdates = await _settingsService.GetSettingAsync(NotifySettingKeys.NotifyOnTradeUpdates); + } + else + { + notifyOnTradeUpdates = _configuration.GetValue("Ntfy:NotifyOnTradeUpdates", true); + } + + if (!notifyOnTradeUpdates) return; + + var trade = JsonSerializer.Deserialize(payloadStr, DefaultJsonOptions); + if (trade == null) return; + + // Resolve which user owns this trade + string username = await _userTradeResolver.ResolveUsernameByUserIdAsync(trade.UserId); + string targetTopic = $"{topicPrefix}_{username}"; + + var notification = _formatter.FormatTradeStatusNotification(trade, targetTopic); + await _ntfyClient.SendNotificationAsync(notification); + } + + private async Task HandleBotTradeStreamAsync(string payloadStr, string topicPrefix) + { + bool notifyOnBotTrades = true; + if (_settingsService != null) + { + notifyOnBotTrades = await _settingsService.GetSettingAsync(NotifySettingKeys.NotifyOnBotTrades); + } + else + { + notifyOnBotTrades = _configuration.GetValue("Ntfy:NotifyOnBotTrades", true); + } + + if (!notifyOnBotTrades) return; + + var botTrade = JsonSerializer.Deserialize(payloadStr, DefaultJsonOptions); + if (botTrade == null) return; + + string targetTopic = $"{topicPrefix}_bot"; + var notification = _formatter.FormatBotTradeNotification(botTrade, targetTopic); + await _ntfyClient.SendNotificationAsync(notification); + } + + private async Task HandleNewsStatusUpdateRequestAsync(UpdateNewsStatusRequest? req, string topic, string correlationId) + { + if (req == null || req.Id == Guid.Empty) return; + + // Only trigger push notifications when an article's status is transitioning to "Analyzed" + if (!string.Equals(req.Status, "Analyzed", StringComparison.OrdinalIgnoreCase)) return; + + bool notifyOnNews = true; + string newsChannel = "news"; + string topicPrefix = "finlytic"; + + if (_settingsService != null) + { + notifyOnNews = await _settingsService.GetSettingAsync(NotifySettingKeys.NotifyOnNews); + newsChannel = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyNewsChannel); + topicPrefix = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyTopicPrefix); + } + else + { + notifyOnNews = _configuration.GetValue("Ntfy:NotifyOnNews", true); + newsChannel = _configuration.GetValue("Ntfy:NewsChannel") ?? "news"; + topicPrefix = _configuration.GetValue("Ntfy:TopicPrefix") ?? "finlytic"; + } + + if (!notifyOnNews) return; + + try + { + // Query FinlyticNews via existing news_GetById RPC channel to get the full enriched NewsArticleDto + var article = await SendRpcRequestAsync( + MqttTopics.Channels.NewsGetById, + new ArticleRequest(req.Id.ToString(), req.Id.ToString()), + TimeSpan.FromSeconds(5)); + + if (article != null) + { + string targetTopic = $"{topicPrefix.Trim('/')}_{newsChannel}"; + var notification = _formatter.FormatNewsNotification(article, targetTopic); + await _ntfyClient.SendNotificationAsync(notification); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[NotifyMqttClient] Failed to fetch analyzed news article {ArticleId} via news_GetById RPC.", req.Id); + } + } + + private async Task HandleHealthPingTopicAsync(object? _, string topic, string correlationId) + { + if (topic.Contains("FinlyticNotify", StringComparison.OrdinalIgnoreCase) || !topic.Contains("/", StringComparison.OrdinalIgnoreCase)) + { + string respTopic = MqttTopics.ResponseTopic(MqttTopics.Channels.HealthPing, correlationId); + await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticNotify", "Online", DateTime.UtcNow, "Connected")); + + if (_finlyticLogger != null) + { + await _finlyticLogger.LogInfoAsync(NotifySettingKeys.HealthPingChannel, + "[FinlyticNotify] Responded to live health_Ping RPC [CorrelationId: {CorrelationId}].", correlationId); + } + } + } + + private async Task> HandleSettingsGetAllRpcAsync(object? _, string correlationId) + { + using var scope = _scopeFactory.CreateScope(); + var settingsService = scope.ServiceProvider.GetRequiredService(); + return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(NotifySettingKeys) }); + } + + private async Task> HandleSettingsUpdateRpcAsync(Dictionary? updates, string correlationId) + { + using var scope = _scopeFactory.CreateScope(); + var settingsService = scope.ServiceProvider.GetRequiredService(); + + if (updates != null && updates.Count > 0) + { + await settingsService.UpdateSettingsAsync(updates); + } + + return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(NotifySettingKeys) }); + } +} diff --git a/FinlyticNotify/Services/NtfyClient.cs b/FinlyticNotify/Services/NtfyClient.cs new file mode 100644 index 0000000..d0728fc --- /dev/null +++ b/FinlyticNotify/Services/NtfyClient.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Services; +using FinlyticNotify.Settings; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace FinlyticNotify.Services; + +/// +/// Model representing a notification payload to be dispatched via ntfy. +/// +public record NtfyNotification( + string Topic, + string Title, + string Message, + int Priority = 3, + List? Tags = null, + string? ClickUrl = null +); + +/// +/// Client interface for sending push notifications to an ntfy instance. +/// +public interface INtfyClient +{ + /// + /// Sends a notification to the specified ntfy topic. + /// + /// The notification payload. + /// Cancellation token. + /// True if successfully delivered, false otherwise. + Task SendNotificationAsync(NtfyNotification notification, CancellationToken cancellationToken = default); +} + +/// +/// High-performance HTTP client for dispatching push notifications to self-hosted ntfy server via JSON payload. +/// +public class NtfyClient : INtfyClient +{ + private readonly HttpClient _httpClient; + private readonly ISettingsService? _settingsService; + private readonly IConfiguration _configuration; + private readonly IFinlyticLogger? _finlyticLogger; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + /// + /// Initializes a new instance of the class. + /// + public NtfyClient( + HttpClient httpClient, + IConfiguration configuration, + ILogger logger, + ISettingsService? settingsService = null, + IFinlyticLogger? finlyticLogger = null) + { + _httpClient = httpClient; + _configuration = configuration; + _logger = logger; + _settingsService = settingsService; + _finlyticLogger = finlyticLogger; + } + + /// + public async Task SendNotificationAsync(NtfyNotification notification, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(notification.Topic)) + { + _logger.LogWarning("[NtfyClient] Aborting send: Topic is empty."); + return false; + } + + string baseUrl = "http://localhost:8080"; + if (_settingsService != null) + { + try + { + baseUrl = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyBaseUrl, cancellationToken); + } + catch + { + baseUrl = _configuration.GetValue("Ntfy:BaseUrl") ?? NotifySettingKeys.NtfyBaseUrl.DefaultValue; + } + } + else + { + baseUrl = _configuration.GetValue("Ntfy:BaseUrl") ?? NotifySettingKeys.NtfyBaseUrl.DefaultValue; + } + + baseUrl = baseUrl.TrimEnd('/'); + + // Build native ntfy JSON payload (preserves full UTF-8 Unicode, Emojis, and Markdown without HTTP header ASCII constraints) + var payload = new Dictionary + { + ["topic"] = notification.Topic.TrimStart('/'), + ["title"] = notification.Title, + ["message"] = notification.Message, + ["priority"] = Math.Clamp(notification.Priority, 1, 5), + ["tags"] = notification.Tags, + ["click"] = notification.ClickUrl, + ["markdown"] = true + }; + + string json = JsonSerializer.Serialize(payload, JsonOptions); + + string? token = null; + string? authUser = null; + string? authPass = null; + + if (_settingsService != null) + { + try + { + token = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyAuthToken, cancellationToken); + authUser = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyUsername, cancellationToken); + authPass = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyPassword, cancellationToken); + } + catch { } + } + + token = string.IsNullOrWhiteSpace(token) ? _configuration.GetValue("Ntfy:AuthToken") : token; + authUser = string.IsNullOrWhiteSpace(authUser) ? _configuration.GetValue("Ntfy:Username") : authUser; + authPass = string.IsNullOrWhiteSpace(authPass) ? _configuration.GetValue("Ntfy:Password") : authPass; + + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, baseUrl); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + + if (!string.IsNullOrWhiteSpace(token)) + { + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.Trim()); + } + else if (!string.IsNullOrWhiteSpace(authUser) && !string.IsNullOrWhiteSpace(authPass)) + { + var authBytes = Encoding.UTF8.GetBytes($"{authUser}:{authPass}"); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(authBytes)); + } + + var response = await _httpClient.SendAsync(request, cancellationToken); + if (response.IsSuccessStatusCode) + { + _logger.LogInformation("[NtfyClient] Push notification successfully delivered to {TargetUrl} (Topic: {Topic})", baseUrl, notification.Topic); + if (_finlyticLogger != null) + { + await _finlyticLogger.LogInfoAsync(NotifySettingKeys.NotificationDeliveryChannel, + "[NtfyDelivery] Push notification successfully delivered to topic '{Topic}' (HTTP {StatusCode})", + notification.Topic, (int)response.StatusCode); + } + return true; + } + + string errorBody = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogWarning("[NtfyClient] Failed to send notification to {TargetUrl} for topic {Topic}. HTTP {Status}: {Body}", + baseUrl, notification.Topic, (int)response.StatusCode, errorBody); + + if (_finlyticLogger != null) + { + await _finlyticLogger.LogWarningAsync(NotifySettingKeys.NotificationDeliveryChannel, + "[NtfyDelivery] Failed to send push notification to topic '{Topic}' (HTTP {StatusCode})", + notification.Topic, (int)response.StatusCode); + } + + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "[NtfyClient] Unexpected error sending push notification to {TargetUrl} for topic {Topic}", baseUrl, notification.Topic); + if (_finlyticLogger != null) + { + await _finlyticLogger.LogErrorAsync(NotifySettingKeys.NotificationDeliveryChannel, ex, + "[NtfyDelivery] Error sending push notification to topic '{Topic}'", notification.Topic); + } + return false; + } + } +} diff --git a/FinlyticNotify/Services/UserTradeResolver.cs b/FinlyticNotify/Services/UserTradeResolver.cs new file mode 100644 index 0000000..ef3d076 --- /dev/null +++ b/FinlyticNotify/Services/UserTradeResolver.cs @@ -0,0 +1,113 @@ +using System; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos; +using FinlyticCore.Services; +using FinlyticCore.Util; +using FinlyticNotify.Settings; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace FinlyticNotify.Services; + +/// +/// Service interface for resolving a UserId GUID to a clean username for ntfy channel addressing. +/// +public interface IUserTradeResolver +{ + /// + /// Resolves the clean username for a given UserId GUID via in-memory cache and FinlyticBackend MQTT RPC. + /// + /// The unique ID of the user owning the trade. + /// Cancellation token. + /// The resolved username (e.g. "lars", "kleidukos", "admin") for ntfy channel addressing. + Task ResolveUsernameByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); +} + +/// +/// Implementation of performing cached MQTT RPC calls to FinlyticBackend +/// without any direct cross-database dependencies. +/// +public class UserTradeResolver : IUserTradeResolver +{ + private readonly IServiceProvider _serviceProvider; + private readonly IMemoryCache _cache; + private readonly ISettingsService _settingsService; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + private static readonly Regex InvalidChannelCharRegex = new("[^a-zA-Z0-9_-]", RegexOptions.Compiled); + + /// + /// Initializes a new instance of the class. + /// + public UserTradeResolver( + IServiceProvider serviceProvider, + IMemoryCache cache, + ISettingsService settingsService, + IConfiguration configuration, + ILogger logger) + { + _serviceProvider = serviceProvider; + _cache = cache; + _settingsService = settingsService; + _configuration = configuration; + _logger = logger; + } + + /// + public async Task ResolveUsernameByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) + { + string defaultUsername = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyDefaultUsername, cancellationToken); + if (string.IsNullOrWhiteSpace(defaultUsername)) + { + defaultUsername = _configuration.GetValue("Ntfy:DefaultUsername") ?? "admin"; + } + + if (userId == Guid.Empty) + { + return defaultUsername; + } + + string cacheKey = $"user_name_{userId}"; + if (_cache.TryGetValue(cacheKey, out string? cachedUsername) && !string.IsNullOrWhiteSpace(cachedUsername)) + { + return cachedUsername; + } + + try + { + var mqttClient = _serviceProvider.GetService(); + if (mqttClient != null && mqttClient.IsConnected) + { + var username = await mqttClient.SendRpcRequestAsync( + MqttTopics.Channels.BackendGetUsername, + new UserIdRequest(userId), + TimeSpan.FromSeconds(2)); + + if (!string.IsNullOrWhiteSpace(username)) + { + string cleanChannel = CleanUsername(username); + _cache.Set(cacheKey, cleanChannel, TimeSpan.FromHours(1)); + return cleanChannel; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[UserTradeResolver] Failed to resolve username from FinlyticBackend for UserId {UserId}. Falling back to default.", userId); + } + + return defaultUsername; + } + + private static string CleanUsername(string rawName) + { + var cleaned = rawName.Trim().ToLowerInvariant().Replace(" ", "_"); + cleaned = InvalidChannelCharRegex.Replace(cleaned, ""); + return string.IsNullOrWhiteSpace(cleaned) ? "admin" : cleaned; + } +} diff --git a/FinlyticNotify/Settings/NotifySettingKeys.cs b/FinlyticNotify/Settings/NotifySettingKeys.cs new file mode 100644 index 0000000..e4b65d7 --- /dev/null +++ b/FinlyticNotify/Settings/NotifySettingKeys.cs @@ -0,0 +1,37 @@ +using FinlyticCore.Models.Settings; + +namespace FinlyticNotify.Settings; + +/// +/// Definition of all typed dynamic configuration keys and standard settings for FinlyticNotify. +/// +public static class NotifySettingKeys +{ + // --- Logging Channels --- + public static readonly SettingKey NotifyChannel = new("Logging.Channel.Notify", true); + public static readonly SettingKey NotificationDeliveryChannel = new("Logging.Channel.NotificationDelivery", true); + public static readonly SettingKey HealthPingChannel = new("Logging.Channel.Health", true); + public static readonly SettingKey MqttChannel = new("Logging.Channel.MQTT", true); + + // --- ntfy Server & Topic Configuration --- + public static readonly SettingKey NtfyBaseUrl = new("Ntfy.BaseUrl", "http://localhost:8080"); + public static readonly SettingKey NtfyTopicPrefix = new("Ntfy.TopicPrefix", "finlytic"); + public static readonly SettingKey NtfyBroadcastChannel = new("Ntfy.BroadcastChannel", "broadcast"); + public static readonly SettingKey NtfyNewsChannel = new("Ntfy.NewsChannel", "news"); + public static readonly SettingKey NtfyDefaultUsername = new("Ntfy.DefaultUsername", "admin"); + + // --- Authentication (Optional for secured/private ntfy instances) --- + public static readonly SettingKey NtfyAuthToken = new("Ntfy.AuthToken", ""); + public static readonly SettingKey NtfyUsername = new("Ntfy.Username", ""); + public static readonly SettingKey NtfyPassword = new("Ntfy.Password", ""); + + // --- Notification Filters & Toggles --- + public static readonly SettingKey NtfyMinProposalScore = new("Ntfy.MinProposalScore", 70.0m); + public static readonly SettingKey NotifyOnProposals = new("Ntfy.NotifyOnProposals", true); + public static readonly SettingKey NotifyOnTradeUpdates = new("Ntfy.NotifyOnTradeUpdates", true); + public static readonly SettingKey NotifyOnBotTrades = new("Ntfy.NotifyOnBotTrades", true); + public static readonly SettingKey NotifyOnNews = new("Ntfy.NotifyOnNews", true); + + // --- Click URL & Frontend Integration --- + public static readonly SettingKey ClickBaseUrl = new("Ntfy.ClickBaseUrl", "http://localhost:3000"); +} diff --git a/FinlyticNotify/appsettings.json b/FinlyticNotify/appsettings.json new file mode 100644 index 0000000..2a6d836 --- /dev/null +++ b/FinlyticNotify/appsettings.json @@ -0,0 +1,26 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=finlytic;Username=postgres;Password=postgres" + }, + "Mqtt": { + "BrokerHost": "localhost", + "BrokerPort": 1883, + "ClientId": "FinlyticNotify" + }, + "Ntfy": { + "BaseUrl": "http://localhost:8080", + "TopicPrefix": "finlytic", + "BroadcastChannel": "broadcast", + "DefaultUsername": "admin", + "MinProposalScore": 70.0, + "NotifyOnProposals": true, + "NotifyOnTradeUpdates": true, + "NotifyOnBotTrades": true + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/docker-compose.ntfy.yml b/docker-compose.ntfy.yml new file mode 100644 index 0000000..4d85fca --- /dev/null +++ b/docker-compose.ntfy.yml @@ -0,0 +1,40 @@ +services: + ntfy: + image: binwiederhier/ntfy:latest + container_name: finlytic-ntfy + command: + - serve + environment: + - NTFY_BASE_URL=http://localhost:8080 + - NTFY_BEHIND_PROXY=false + - NTFY_CACHE_FILE=/var/cache/ntfy/cache.db + - NTFY_AUTH_DEFAULT_ACCESS=read-write + volumes: + - ntfy_cache:/var/cache/ntfy + ports: + - "8080:80" + restart: unless-stopped + + finlytic-notify: + build: + context: . + dockerfile: FinlyticNotify/Dockerfile + container_name: finlytic-notify + environment: + - ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=finlytic;Username=postgres;Password=postgres + - Mqtt__BrokerHost=mosquitto + - Mqtt__BrokerPort=1883 + - Ntfy__BaseUrl=http://ntfy:80 + - Ntfy__TopicPrefix=finlytic + - Ntfy__BroadcastChannel=broadcast + - Ntfy__DefaultUsername=admin + - Ntfy__MinProposalScore=70.0 + - Ntfy__NotifyOnProposals=true + - Ntfy__NotifyOnTradeUpdates=true + - Ntfy__NotifyOnBotTrades=true + depends_on: + - ntfy + restart: unless-stopped + +volumes: + ntfy_cache: