feat(notify): add FinlyticNotify push notification microservice and test suite
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user