feat(notify): add FinlyticNotify push notification microservice and test suite
This commit is contained in:
@@ -29,6 +29,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticEngine.Tests", "Fin
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticBot.Tests", "FinlyticBot.Tests\FinlyticBot.Tests.csproj", "{1E282E4D-C63E-49E6-879D-DDEEDA530E47}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticBot.Tests", "FinlyticBot.Tests\FinlyticBot.Tests.csproj", "{1E282E4D-C63E-49E6-879D-DDEEDA530E47}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{1E282E4D-C63E-49E6-879D-DDEEDA530E47}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\FinlyticCore\FinlyticCore.csproj" />
|
||||||
|
<ProjectReference Include="..\FinlyticNotify\FinlyticNotify.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using FinlyticCore.Database;
|
||||||
|
using FinlyticCore.Entities.Settings;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FinlyticNotify.Database;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entity Framework DbContext used by FinlyticNotify exclusively for its own database (finlytic_notify)
|
||||||
|
/// and dynamic settings.
|
||||||
|
/// </summary>
|
||||||
|
public class NotifyDbContext : DbContext, ISettingsDbContext
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="NotifyDbContext"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public NotifyDbContext(DbContextOptions<NotifyDbContext> options) : base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Dynamic settings table for FinlyticNotify.</summary>
|
||||||
|
public DbSet<SettingEntity> DynamicSettings => Set<SettingEntity>();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SettingEntity>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasIndex(e => e.Key).IsUnique();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\FinlyticCore\FinlyticCore.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ValueJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("DynamicSettings");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticNotify.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Init : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "DynamicSettings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Key = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
|
||||||
|
ValueJson = table.Column<string>(type: "text", nullable: false),
|
||||||
|
ServiceIdentifier = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||||
|
LastUpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_DynamicSettings", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_DynamicSettings_Key",
|
||||||
|
table: "DynamicSettings",
|
||||||
|
column: "Key",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "DynamicSettings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(150)
|
||||||
|
.HasColumnType("character varying(150)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastUpdatedUtc")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("ServiceIdentifier")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ValueJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("DynamicSettings");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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<NotifyDbContext>(options =>
|
||||||
|
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||||
|
builder.Services.AddScoped<ISettingsDbContext>(sp => sp.GetRequiredService<NotifyDbContext>());
|
||||||
|
|
||||||
|
// 2. Register Core Services & Logger
|
||||||
|
builder.Services.AddSingleton<ISettingsService, SettingsService>();
|
||||||
|
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<INtfyClient, NtfyClient>()
|
||||||
|
.ConfigureHttpClient(client =>
|
||||||
|
{
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Register Domain Services
|
||||||
|
builder.Services.AddSingleton<IUserTradeResolver, UserTradeResolver>();
|
||||||
|
builder.Services.AddSingleton<INotificationFormatter, NotificationFormatter>();
|
||||||
|
|
||||||
|
// 6. Register MQTT Listener & Background Service
|
||||||
|
builder.Services.AddSingleton<NotifyMqttClient>();
|
||||||
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<NotifyMqttClient>());
|
||||||
|
|
||||||
|
var host = builder.Build();
|
||||||
|
|
||||||
|
using (var scope = host.Services.CreateScope())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var context = scope.ServiceProvider.GetRequiredService<NotifyDbContext>();
|
||||||
|
var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? "";
|
||||||
|
await context.MigrateWithBootstrapAsync(connStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
|
||||||
|
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();
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service interface for transforming trading and news events into structured ntfy push notifications.
|
||||||
|
/// </summary>
|
||||||
|
public interface INotificationFormatter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a new trade proposal into a high-priority opportunity notification.
|
||||||
|
/// </summary>
|
||||||
|
NtfyNotification FormatProposalNotification(TradeProposalDto proposal, string targetTopic);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats an active trade lifecycle change into a user-specific status notification.
|
||||||
|
/// </summary>
|
||||||
|
NtfyNotification FormatTradeStatusNotification(ActiveTradeDto trade, string targetTopic);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats an automated paper-trading bot execution event into a notification.
|
||||||
|
/// </summary>
|
||||||
|
NtfyNotification FormatBotTradeNotification(BotTradeOrderDto botTrade, string targetTopic);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats an analyzed news article with sentiment evaluation into a push notification.
|
||||||
|
/// </summary>
|
||||||
|
NtfyNotification FormatNewsNotification(NewsArticleDto article, string targetTopic);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implementation of <see cref="INotificationFormatter"/> that creates emoji-rich Markdown messages
|
||||||
|
/// formatted for the ntfy mobile/web applications.
|
||||||
|
/// </summary>
|
||||||
|
public class NotificationFormatter : INotificationFormatter
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<string>
|
||||||
|
{
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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"]
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<string> { "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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<string> { "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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Managed MQTT client for FinlyticNotify. Listens strictly to existing MQTT broadcast topics,
|
||||||
|
/// resolves trade ownership, and dispatches rich push notifications via ntfy.
|
||||||
|
/// </summary>
|
||||||
|
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<NotifyMqttClient>? _finlyticLogger;
|
||||||
|
private readonly ILogger<NotifyMqttClient> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="NotifyMqttClient"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public NotifyMqttClient(
|
||||||
|
IConfiguration configuration,
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
INtfyClient ntfyClient,
|
||||||
|
INotificationFormatter formatter,
|
||||||
|
IUserTradeResolver userTradeResolver,
|
||||||
|
ILogger<NotifyMqttClient> logger,
|
||||||
|
ISettingsService? settingsService = null,
|
||||||
|
IFinlyticLogger<NotifyMqttClient>? finlyticLogger = null) : base(logger)
|
||||||
|
{
|
||||||
|
_configuration = configuration;
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_ntfyClient = ntfyClient;
|
||||||
|
_formatter = formatter;
|
||||||
|
_userTradeResolver = userTradeResolver;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_finlyticLogger = finlyticLogger;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<UpdateNewsStatusRequest>(
|
||||||
|
MqttTopics.RequestFilter(MqttTopics.Channels.NewsUpdateStatus), HandleNewsStatusUpdateRequestAsync);
|
||||||
|
|
||||||
|
// 5. Subscribe to Service Health Ping for fleet monitoring
|
||||||
|
await SubscribeAsync<object>(MqttTopics.RequestFilter(MqttTopics.Channels.HealthPing), HandleHealthPingTopicAsync);
|
||||||
|
|
||||||
|
// 6. Subscribe to Dynamic Settings RPC Channels
|
||||||
|
await SubscribeRpcAsync<object, List<DynamicSettingDto>>(
|
||||||
|
MqttTopics.RequestFilter(MqttTopics.Channels.NotifySettingsGetAll), HandleSettingsGetAllRpcAsync);
|
||||||
|
await SubscribeRpcAsync<Dictionary<string, object?>, List<DynamicSettingDto>>(
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<string>("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<bool>("Ntfy:NotifyOnProposals", true);
|
||||||
|
minScore = _configuration.GetValue<decimal>("Ntfy:MinProposalScore", 70.0m);
|
||||||
|
broadcastChannel = _configuration.GetValue<string>("Ntfy:BroadcastChannel") ?? "broadcast";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!notifyOnProposals) return;
|
||||||
|
|
||||||
|
var proposal = JsonSerializer.Deserialize<TradeProposalDto>(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<bool>("Ntfy:NotifyOnTradeUpdates", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!notifyOnTradeUpdates) return;
|
||||||
|
|
||||||
|
var trade = JsonSerializer.Deserialize<ActiveTradeDto>(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<bool>("Ntfy:NotifyOnBotTrades", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!notifyOnBotTrades) return;
|
||||||
|
|
||||||
|
var botTrade = JsonSerializer.Deserialize<BotTradeOrderDto>(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<bool>("Ntfy:NotifyOnNews", true);
|
||||||
|
newsChannel = _configuration.GetValue<string>("Ntfy:NewsChannel") ?? "news";
|
||||||
|
topicPrefix = _configuration.GetValue<string>("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<NewsArticleDto, ArticleRequest>(
|
||||||
|
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<List<DynamicSettingDto>> HandleSettingsGetAllRpcAsync(object? _, string correlationId)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||||
|
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(NotifySettingKeys) });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<DynamicSettingDto>> HandleSettingsUpdateRpcAsync(Dictionary<string, object?>? updates, string correlationId)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
|
||||||
|
|
||||||
|
if (updates != null && updates.Count > 0)
|
||||||
|
{
|
||||||
|
await settingsService.UpdateSettingsAsync(updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(NotifySettingKeys) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Model representing a notification payload to be dispatched via ntfy.
|
||||||
|
/// </summary>
|
||||||
|
public record NtfyNotification(
|
||||||
|
string Topic,
|
||||||
|
string Title,
|
||||||
|
string Message,
|
||||||
|
int Priority = 3,
|
||||||
|
List<string>? Tags = null,
|
||||||
|
string? ClickUrl = null
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client interface for sending push notifications to an ntfy instance.
|
||||||
|
/// </summary>
|
||||||
|
public interface INtfyClient
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a notification to the specified ntfy topic.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="notification">The notification payload.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>True if successfully delivered, false otherwise.</returns>
|
||||||
|
Task<bool> SendNotificationAsync(NtfyNotification notification, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// High-performance HTTP client for dispatching push notifications to self-hosted ntfy server via JSON payload.
|
||||||
|
/// </summary>
|
||||||
|
public class NtfyClient : INtfyClient
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly ISettingsService? _settingsService;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly IFinlyticLogger<NtfyClient>? _finlyticLogger;
|
||||||
|
private readonly ILogger<NtfyClient> _logger;
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="NtfyClient"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public NtfyClient(
|
||||||
|
HttpClient httpClient,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<NtfyClient> logger,
|
||||||
|
ISettingsService? settingsService = null,
|
||||||
|
IFinlyticLogger<NtfyClient>? finlyticLogger = null)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_configuration = configuration;
|
||||||
|
_logger = logger;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_finlyticLogger = finlyticLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<bool> 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<string>("Ntfy:BaseUrl") ?? NotifySettingKeys.NtfyBaseUrl.DefaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
baseUrl = _configuration.GetValue<string>("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<string, object?>
|
||||||
|
{
|
||||||
|
["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<string>("Ntfy:AuthToken") : token;
|
||||||
|
authUser = string.IsNullOrWhiteSpace(authUser) ? _configuration.GetValue<string>("Ntfy:Username") : authUser;
|
||||||
|
authPass = string.IsNullOrWhiteSpace(authPass) ? _configuration.GetValue<string>("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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service interface for resolving a UserId GUID to a clean username for ntfy channel addressing.
|
||||||
|
/// </summary>
|
||||||
|
public interface IUserTradeResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the clean username for a given UserId GUID via in-memory cache and FinlyticBackend MQTT RPC.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userId">The unique ID of the user owning the trade.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The resolved username (e.g. "lars", "kleidukos", "admin") for ntfy channel addressing.</returns>
|
||||||
|
Task<string> ResolveUsernameByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implementation of <see cref="IUserTradeResolver"/> performing cached MQTT RPC calls to FinlyticBackend
|
||||||
|
/// without any direct cross-database dependencies.
|
||||||
|
/// </summary>
|
||||||
|
public class UserTradeResolver : IUserTradeResolver
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly ILogger<UserTradeResolver> _logger;
|
||||||
|
|
||||||
|
private static readonly Regex InvalidChannelCharRegex = new("[^a-zA-Z0-9_-]", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="UserTradeResolver"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public UserTradeResolver(
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
IMemoryCache cache,
|
||||||
|
ISettingsService settingsService,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<UserTradeResolver> logger)
|
||||||
|
{
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
_cache = cache;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
_configuration = configuration;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<string> ResolveUsernameByUserIdAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string defaultUsername = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyDefaultUsername, cancellationToken);
|
||||||
|
if (string.IsNullOrWhiteSpace(defaultUsername))
|
||||||
|
{
|
||||||
|
defaultUsername = _configuration.GetValue<string>("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<NotifyMqttClient>();
|
||||||
|
if (mqttClient != null && mqttClient.IsConnected)
|
||||||
|
{
|
||||||
|
var username = await mqttClient.SendRpcRequestAsync<string, UserIdRequest>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using FinlyticCore.Models.Settings;
|
||||||
|
|
||||||
|
namespace FinlyticNotify.Settings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Definition of all typed dynamic configuration keys and standard settings for FinlyticNotify.
|
||||||
|
/// </summary>
|
||||||
|
public static class NotifySettingKeys
|
||||||
|
{
|
||||||
|
// --- Logging Channels ---
|
||||||
|
public static readonly SettingKey<bool> NotifyChannel = new("Logging.Channel.Notify", true);
|
||||||
|
public static readonly SettingKey<bool> NotificationDeliveryChannel = new("Logging.Channel.NotificationDelivery", true);
|
||||||
|
public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true);
|
||||||
|
public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true);
|
||||||
|
|
||||||
|
// --- ntfy Server & Topic Configuration ---
|
||||||
|
public static readonly SettingKey<string> NtfyBaseUrl = new("Ntfy.BaseUrl", "http://localhost:8080");
|
||||||
|
public static readonly SettingKey<string> NtfyTopicPrefix = new("Ntfy.TopicPrefix", "finlytic");
|
||||||
|
public static readonly SettingKey<string> NtfyBroadcastChannel = new("Ntfy.BroadcastChannel", "broadcast");
|
||||||
|
public static readonly SettingKey<string> NtfyNewsChannel = new("Ntfy.NewsChannel", "news");
|
||||||
|
public static readonly SettingKey<string> NtfyDefaultUsername = new("Ntfy.DefaultUsername", "admin");
|
||||||
|
|
||||||
|
// --- Authentication (Optional for secured/private ntfy instances) ---
|
||||||
|
public static readonly SettingKey<string> NtfyAuthToken = new("Ntfy.AuthToken", "");
|
||||||
|
public static readonly SettingKey<string> NtfyUsername = new("Ntfy.Username", "");
|
||||||
|
public static readonly SettingKey<string> NtfyPassword = new("Ntfy.Password", "");
|
||||||
|
|
||||||
|
// --- Notification Filters & Toggles ---
|
||||||
|
public static readonly SettingKey<decimal> NtfyMinProposalScore = new("Ntfy.MinProposalScore", 70.0m);
|
||||||
|
public static readonly SettingKey<bool> NotifyOnProposals = new("Ntfy.NotifyOnProposals", true);
|
||||||
|
public static readonly SettingKey<bool> NotifyOnTradeUpdates = new("Ntfy.NotifyOnTradeUpdates", true);
|
||||||
|
public static readonly SettingKey<bool> NotifyOnBotTrades = new("Ntfy.NotifyOnBotTrades", true);
|
||||||
|
public static readonly SettingKey<bool> NotifyOnNews = new("Ntfy.NotifyOnNews", true);
|
||||||
|
|
||||||
|
// --- Click URL & Frontend Integration ---
|
||||||
|
public static readonly SettingKey<string> ClickBaseUrl = new("Ntfy.ClickBaseUrl", "http://localhost:3000");
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
Reference in New Issue
Block a user