Files
Finlytic/FinlyticNotify/Services/NotificationFormatter.cs
T

261 lines
11 KiB
C#

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
);
}
}