feat(notify): add FinlyticNotify push notification microservice and test suite

This commit is contained in:
2026-09-01 17:38:01 +02:00
parent 6a0f9af3f8
commit c5d7d359ba
17 changed files with 1677 additions and 0 deletions
@@ -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);
}
}